diff --git a/backend/Dockerfile b/backend/Dockerfile index 227dbda65..12fb3abfd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -59,7 +59,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \ CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="$ldflags" -o /out/migrate-schema ./cmd/migrate-schema; \ CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="$ldflags" -o /out/migrate-sqlite-postgres ./cmd/migrate-sqlite-postgres -FROM alpine:3.22 +FROM node:22.19-alpine AS pi-runtime-deps + +WORKDIR /app/backend/agent-runtime/pi +COPY backend/agent-runtime/pi/package.json backend/agent-runtime/pi/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts + +FROM node:22.19-alpine RUN apk add --no-cache ca-certificates tzdata wget \ && addgroup -S app \ @@ -75,6 +81,9 @@ COPY --from=backend-build /out/infinite-canvas-backend /usr/local/bin/infinite-c COPY --from=backend-build /out/migrate-schema /usr/local/bin/migrate-schema COPY --from=backend-build /out/migrate-sqlite-postgres /usr/local/bin/migrate-sqlite-postgres COPY --from=payment-package-smoke /app/plugin-packages /app/plugin-packages +COPY --from=pi-runtime-deps /app/backend/agent-runtime/pi/node_modules /app/backend/agent-runtime/pi/node_modules +COPY backend/agent-runtime/pi/package.json backend/agent-runtime/pi/package-lock.json /app/backend/agent-runtime/pi/ +COPY backend/agent-runtime/pi/agent-runtime.mjs /app/backend/agent-runtime/pi/agent-runtime.mjs USER app EXPOSE 8080 diff --git a/backend/agent-runtime/pi/agent-runtime.mjs b/backend/agent-runtime/pi/agent-runtime.mjs new file mode 100644 index 000000000..45c1e85f5 --- /dev/null +++ b/backend/agent-runtime/pi/agent-runtime.mjs @@ -0,0 +1,424 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; + +const providerID = "infinite-canvas"; +const api = "openai-completions"; + +async function readRequest() { + let raw = ""; + for await (const chunk of process.stdin) raw += chunk; + if (!raw.trim()) throw new Error("Agent runtime input is empty"); + return JSON.parse(raw); +} + +function emit(event, payload = {}) { + process.stdout.write(`${JSON.stringify({ event, ...payload })}\n`); +} + +function createMessage(model, result) { + const content = []; + if (result.text) content.push({ type: "text", text: result.text }); + for (const call of result.toolCalls ?? []) { + content.push({ + type: "toolCall", + id: call.id, + name: call.name, + arguments: call.arguments ?? {}, + }); + } + return { + role: "assistant", + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: Number(result.usage?.input ?? 0), + output: Number(result.usage?.output ?? 0), + cacheRead: Number(result.usage?.cacheRead ?? 0), + cacheWrite: Number(result.usage?.cacheWrite ?? 0), + totalTokens: Number(result.usage?.totalTokens ?? 0), + cost: result.usage?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop", + timestamp: Date.now(), + }; +} + +async function bridge(request, path, body, signal) { + const response = await fetch(new URL(path, request.bridgeURL), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${request.bridgeToken}`, + }, + body: JSON.stringify(body), + signal, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Agent bridge returned HTTP ${response.status}`); + return data; +} + +async function run() { + const request = await readRequest(); + if (!request.bridgeURL || !request.bridgeToken || !request.model?.id) { + throw new Error("Agent runtime is missing its bridge, session, or model configuration"); + } + + const modelRuntime = await ModelRuntime.create({ agentDir: request.agentDir }); + modelRuntime.registerProvider(providerID, { + name: "影策模型任务", + baseUrl: "http://agent-runtime.invalid/v1", + apiKey: "managed-by-go-bridge", + api, + authHeader: false, + models: [{ + id: request.model.id, + name: request.model.name || request.model.id, + api, + reasoning: Boolean(request.model.reasoning), + input: request.model.input?.length ? request.model.input : ["text", "image"], + cost: request.model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: Math.max(1, Number(request.model.contextWindow || 128000)), + maxTokens: Math.max(1, Number(request.model.maxTokens || 8192)), + }], + streamSimple(model, context, options) { + const stream = createAssistantMessageEventStream(); + const partial = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "pending", + timestamp: Date.now(), + }; + void (async () => { + stream.push({ type: "start", partial }); + try { + const result = await bridge(request, "/model", { + modelId: model.id, + messages: context.messages, + tools: (context.tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })), + thinkingLevel: options?.reasoning, + }, options?.signal); + for (const text of result.steeringMessages ?? []) { + await session.steer(text); + } + const message = createMessage(model, result); + if (result.text) { + partial.content.push({ type: "text", text: result.text }); + stream.push({ type: "text_start", contentIndex: 0, partial }); + stream.push({ type: "text_delta", contentIndex: 0, delta: result.text, partial }); + stream.push({ type: "text_end", contentIndex: 0, content: result.text, partial }); + } + let contentIndex = result.text ? 1 : 0; + for (const call of result.toolCalls ?? []) { + const toolCall = message.content.find((item) => item.type === "toolCall" && item.id === call.id); + partial.content.push(toolCall); + stream.push({ type: "toolcall_start", contentIndex, partial }); + stream.push({ type: "toolcall_end", contentIndex, toolCall, partial }); + contentIndex++; + } + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(message); + } catch (error) { + stream.push({ type: "error", reason: "error", error: { + ...partial, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + } }); + stream.end(); + } + })(); + return stream; + }, + }); + + const model = modelRuntime.getModel(providerID, request.model.id); + if (!model) throw new Error("Agent model registration failed"); + + let session; + // 工具进入审批时主动中止本轮;这是暂停,不是失败。 + let pausedForApproval = false; + const tools = (request.tools ?? []).map((tool) => ({ + name: tool.name, + label: tool.label || tool.name, + description: tool.description || tool.name, + parameters: Type.Unsafe(tool.parameters ?? { type: "object", properties: {} }), + executionMode: tool.executionMode || "sequential", + async execute(toolCallId, params, signal) { + const result = await bridge(request, "/tool", { + callId: toolCallId, + name: tool.name, + arguments: params, + }, signal); + if (result.pause) { + pausedForApproval = true; + emit("approval_wait", { approvalId: result.approvalId, toolName: tool.name, callId: toolCallId }); + session.abort(); + return { + content: [{ type: "text", text: result.content || "操作正在等待用户审批。" }], + details: { approvalId: result.approvalId, paused: true }, + isError: true, + }; + } + // 工具结果必须是非空文本:交回 "null" 会让模型返回空回复。 + let text = typeof result.content === "string" ? result.content : JSON.stringify(result.content ?? result ?? {}); + if (!text || text === "null") text = JSON.stringify({ error: "工具没有返回结果" }); + return { + content: [{ type: "text", text }], + details: result.details, + isError: Boolean(result.isError), + }; + }, + })); + + let sessionManager; + if (request.sessionJSONL) { + if (!request.sessionFile) throw new Error("Agent session restore requires a session file path"); + await mkdir(dirname(request.sessionFile), { recursive: true, mode: 0o700 }); + await writeFile(request.sessionFile, request.sessionJSONL, { mode: 0o600, flag: "w" }); + sessionManager = SessionManager.open(request.sessionFile, undefined, request.cwd); + } else { + sessionManager = SessionManager.create(request.cwd, request.sessionDir); + } + const resourceLoader = new DefaultResourceLoader({ + cwd: request.cwd, + agentDir: request.agentDir, + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + additionalSkillPaths: request.skillPaths ?? [], + systemPrompt: request.systemPrompt, + }); + await resourceLoader.reload(); + + const contextWindow = Math.max(1, Number(request.model?.contextWindow || 128000)); + const sessionConfig = { + cwd: request.cwd, + agentDir: request.agentDir, + modelRuntime, + model, + resourceLoader, + sessionManager, + customTools: tools, + }; + + if (request.profile) { + sessionConfig.profile = { + revision: request.profile.revision, + hash: request.profile.hash, + layers: request.profile.layers || [], + }; + } + + if (request.memory?.enabled) { + sessionConfig.memory = { + enabled: true, + storePath: request.memory.storePath, + userId: request.userId, + canvasId: request.canvasId, + maxEntries: request.memory.maxEntries || 1000, + }; + } + + if (request.canvas) { + sessionConfig.canvas = request.canvas; + } + + if (request.features) { + sessionConfig.features = { + planning: request.features.planningEnabled ?? true, + forms: request.features.formsEnabled ?? true, + approval: request.features.approvalRequired ?? [], + }; + } + + if (request.permissions) { + sessionConfig.permissions = request.permissions; + } + + ({ session } = await createAgentSession(sessionConfig)); + + const compactionConfig = request.compaction || { + enabled: true, + strategy: "balanced", + reserveTokens: Math.max(1024, Math.ceil(contextWindow * 0.2)), + keepRecentTokens: Math.min(20000, Math.max(1024, Math.floor(contextWindow * 0.2))), + }; + + session.settingsManager.applyOverrides({ + compaction: compactionConfig, + // 重试只由 Go 模型桥决定(按上游真实 HTTP 状态区分临时故障与参数错误); + // 这里再重试一层会让 400 也被重发、让 500 被重试 3×3 次。 + retry: { enabled: false }, + }); + + const eventChain = { current: Promise.resolve() }; + let runtimeError = null; + const enqueueEvent = (payload) => { + eventChain.current = eventChain.current.then(() => bridge(request, "/event", payload)); + }; + + session.subscribe((event) => { + // 转发完整的 Pi 事件流到 Go 后端 + if (event.type === "message_start") { + const message = event.message; + if (message?.role === "assistant") { + enqueueEvent({ + type: "message_start", + role: "assistant", + messageId: event.messageId, + }); + } + } else if (event.type === "message_delta") { + enqueueEvent({ + type: "message_delta", + delta: event.delta, + messageId: event.messageId, + }); + } else if (event.type === "message_end") { + const message = event.message; + if (message?.role !== "assistant") return; + if (pausedForApproval || message.stopReason === "aborted") return; + if (message.stopReason === "error" || message.errorMessage) { + runtimeError = new Error(message.errorMessage || "Agent assistant message failed"); + return; + } + enqueueEvent({ + type: "message_end", + message, + contextUsage: session.getContextUsage(), + }); + } else if (event.type === "thinking_start") { + enqueueEvent({ + type: "thinking_start", + thinkingId: event.thinkingId, + }); + } else if (event.type === "thinking_delta") { + enqueueEvent({ + type: "thinking_delta", + delta: event.delta, + thinkingId: event.thinkingId, + }); + } else if (event.type === "thinking_end") { + enqueueEvent({ + type: "thinking_end", + content: event.content, + thinkingId: event.thinkingId, + }); + } else if (event.type === "planning_start") { + enqueueEvent({ + type: "planning_start", + planId: event.planId, + }); + } else if (event.type === "planning_update") { + enqueueEvent({ + type: "planning_update", + plan: event.plan, + planId: event.planId, + }); + } else if (event.type === "planning_end") { + enqueueEvent({ + type: "planning_end", + plan: event.plan, + planId: event.planId, + }); + } else if (event.type === "form_start") { + enqueueEvent({ + type: "form_start", + form: event.form, + formId: event.formId, + }); + } else if (event.type === "form_update") { + enqueueEvent({ + type: "form_update", + form: event.form, + formId: event.formId, + }); + } else if (event.type === "form_submit") { + enqueueEvent({ + type: "form_submit", + answers: event.answers, + formId: event.formId, + }); + } else if (event.type === "tool_call_start") { + enqueueEvent({ + type: "tool_call_start", + toolCall: event.toolCall, + callId: event.callId, + }); + } else if (event.type === "tool_call_end") { + enqueueEvent({ + type: "tool_call_end", + result: event.result, + callId: event.callId, + }); + } else if (event.type === "approval_requested") { + enqueueEvent({ + type: "approval_requested", + approval: event.approval, + approvalId: event.approvalId, + }); + } else if (event.type === "entry_appended") { + eventChain.current = eventChain.current.then(async () => { + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) return; + let sessionJSONL; + try { + sessionJSONL = await readFile(sessionFile, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + return bridge(request, "/event", { + type: "session_snapshot", + sessionJSONL, + contextUsage: session.getContextUsage(), + }); + }); + } else if (event.type === "compaction_start" || event.type === "compaction_end") { + enqueueEvent({ type: event.type, reason: event.reason, contextUsage: session.getContextUsage() }); + } + }); + + try { + try { + await session.prompt(request.prompt); + } catch (error) { + if (!pausedForApproval) throw error; + } + await eventChain.current; + if (runtimeError && !pausedForApproval) throw runtimeError; + const sessionFile = sessionManager.getSessionFile(); + const sessionJSONL = await readFile(sessionFile, "utf8"); + await bridge(request, "/event", { + type: "session_snapshot", + sessionJSONL, + contextUsage: session.getContextUsage(), + }); + emit("settled", { + contextUsage: session.getContextUsage(), + sessionFile, + entries: sessionManager.getEntries().length, + }); + } finally { + session.dispose(); + } +} + +run().catch((error) => { + emit("runtime_error", { message: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; +}); diff --git a/backend/agent-runtime/pi/package-lock.json b/backend/agent-runtime/pi/package-lock.json new file mode 100644 index 000000000..1364b967a --- /dev/null +++ b/backend/agent-runtime/pi/package-lock.json @@ -0,0 +1,3005 @@ +{ + "name": "@infinite-canvas/pi-agent-runtime", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@infinite-canvas/pi-agent-runtime", + "dependencies": { + "@earendil-works/pi-ai": "0.87.1", + "@earendil-works/pi-coding-agent": "0.87.1", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "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.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "license": "Apache-2.0", + "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.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "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.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.1.tgz", + "integrity": "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==", + "license": "MIT", + "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", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.87.1.tgz", + "integrity": "sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.87.1", + "@earendil-works/pi-agent-core": "^0.87.1", + "@earendil-works/pi-ai": "^0.87.1", + "@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" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.87.1.tgz", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.87.1.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.87.1", + "@earendil-works/pi-ai": "^0.87.1", + "@earendil-works/pi-telemetry": "^0.87.1", + "diff": "8.0.4", + "ignore": "7.0.8", + "typebox": "1.3.27", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.1.tgz", + "license": "MIT", + "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", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.1.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.87.1.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "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 + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.1.tgz", + "integrity": "sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", + "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "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 + } + } + } + } +} diff --git a/backend/agent-runtime/pi/package.json b/backend/agent-runtime/pi/package.json new file mode 100644 index 000000000..f8aa31d1e --- /dev/null +++ b/backend/agent-runtime/pi/package.json @@ -0,0 +1,13 @@ +{ + "name": "@infinite-canvas/pi-agent-runtime", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "@earendil-works/pi-ai": "0.87.1", + "@earendil-works/pi-coding-agent": "0.87.1", + "typebox": "1.3.27" + } +} diff --git a/backend/builtin/影策短剧制作工作流/SKILL.md b/backend/builtin/影策短剧制作工作流/SKILL.md index 1a9f42382..dd37b6c8a 100644 --- a/backend/builtin/影策短剧制作工作流/SKILL.md +++ b/backend/builtin/影策短剧制作工作流/SKILL.md @@ -1,6 +1,6 @@ --- name: 影策短剧制作工作流 -description: 将小说章节、故事素材或定稿剧本整理为可审阅的短剧方案,在影策画布中创建结构化分镜、组织真实资产、按可用模型生成视频片段,并用可追溯的状态报告交付结果。 +description: 将小说章节、故事素材或定稿剧本整理为可审阅的短剧方案;先确认统一视觉风格并建立、验收、锁定角色/服装/场景/道具资产,再进入制作版分镜和视频生成,最后按真实任务状态核验交付。 metadata: version: "1.0.0" author: 影策 @@ -47,18 +47,21 @@ metadata: 只有缺失项会实质改变结果时才提问。一个问题只解决一个选择;用户已明确授权自主决定时采用合理默认,并在报告中写出假设。 -### B. 影策画布工作阶段 +### B. 分阶段生产与阻断门槛 -1. 用 `canvas_get_state` 读取当前画布、节点、连线和 `snapshotHash`。 -2. 复用已有的 `text`、`markdown`、`script`、`image`、`audio`、`video` 节点;不得凭名称猜节点 ID。 -3. 文本整理结果写入 `text` 或 `markdown` 节点;多镜头分镜使用 `canvas_create_storyboard` 创建 `script` 节点。 -4. 修改已有分镜前,用 `canvas_read_storyboard` 获取真实 `rowId` 和最新快照,再用 `canvas_edit_storyboard` 做单行修改。 -5. 创建普通节点或引用关系使用 `canvas_apply_ops`;只用 `canvas_list_node_types` 返回的节点类型和字段。 -6. 生成前用 `model_list` 查询当前真实能力、参考数量、时长、画幅、音频和价格,再把同一 selection 传给 `generate_media`。 -7. 生成后用 `task_get` 或真实事件状态核验,不用节点标题、文件名或模型回复推断完成。 -8. 用 `canvas_arrange_nodes` 整理本轮节点,最后写入结构化 Markdown 报告。 +按以下阶段推进,不得为了尽快出视频跳过设计和资产验收。每阶段结束时报告真实状态;未通过门槛就停在当前阶段。 -### C. 权限与费用 +1. **项目读取与范围锁定**:用 `canvas_get_state` 读取画布、节点、连线、任务和 `snapshotHash`;明确素材范围、集数/场次、目标交付、时长、画幅及用户硬约束。续作只继承可核实的已采用状态。 +2. **剧情与剧本**:建立事实账本,完成必要改编并标注提案/未知;用户提供定稿时不擅自改写。可先写剧情拆解、场次剧本和非制作级草分镜。 +3. **风格确认**:在制作级资产图和正式分镜之前,向用户展示可比较的视觉方向,至少覆盖媒介/质感、时代地域、色彩基调、光影、构图和画幅。用户已有明确风格时记录并复述确认;用户授权自主决定时给出一个明确方案及假设。风格未确认,不生成正式资产或进入制作级分镜。 +4. **资产圣经建立与验收**:依 `references/assets.md` 为本次实际会出镜或影响连续性的角色、服装状态、场景、关键道具/武器、必要声音建立登记;产出当前模型能力允许的角色多视图/转面参考、场景空间母版/多角度参考、道具结构参考。资产逐项核对、标记版本和状态,并由用户确认或依明确授权采用。不能生成或读取参考图时,写明缺口和影响,不得声称资产已锁定。 +5. **正式分镜与连续性检查**:只有风格已确认且所有本阶段必需资产达到“已采用/已锁定”,才能将分镜标为制作版并绑定真实资产引用。草分镜可以先行,但必须标注“草案,不可投产”。正式分镜逐镜记录人物/服装变体、场景状态、道具状态、时间、机位、动作、对白/声音、时长及引用资产。 +6. **模型与生成授权**:每个生成批次调用 `model_list` 查询当前能力、参考数、时长、画幅、声音和费用;检查实际选用的参考节点是否为锁定版本。向用户说明对象、数量、模型/规格及费用范围,取得针对该批次的授权后再调用 `generate_media`。音色资产仅在用户选择需要且能力支持时准备,不是必需前置。 +7. **生成、验收与交付**:通过 `task_get` 或真实事件状态核验;逐片检查角色/服装、场景、道具、动作因果、对白声音、时长和画幅。未核验、失败或不一致的片段不得作为合格成片交付;保留候选和失败记录,修订只重跑受影响部分并重新授权。 + +**硬停止条件**:风格未定;主角/关键角色的识别参考缺失或冲突;当集主要场景母版缺失;关键道具/武器没有可复用定义;跨镜服装/伤态/空间状态未记录;正式分镜没有真实资产引用;引用资源无法读取或未验收。遇到任一项,只能继续做文本方案、草分镜或资产补齐,不得启动视频生成。 + +画布操作顺序仍遵循真实能力:复用已核实节点;使用 `canvas_list_node_types` 查合法类型;分镜通过 `canvas_create_storyboard` 创建、修改前 `canvas_read_storyboard`;普通节点/引用通过 `canvas_apply_ops`;整理使用 `canvas_arrange_nodes`。工具参数、审批和失败恢复以系统级合同及本轮真实 schema 为准。 - `canvas_apply_ops`、分镜写入和媒体生成属于写操作,遵循当前 Agent 权限模式。 - `generate_media` 是可能收费的操作。用户未明确授权时,先输出方案和预计范围,使用 `ask_user` 获取一次具体确认。 @@ -94,6 +97,15 @@ metadata: | 当前版本 | 可核实的文本/分镜版本 | | 用户硬约束 | 时长、画幅、语言、风格、声音 | +## 风格与投产门槛 +| 阶段 | 状态/依据 | 阻塞项 | +|---|---|---| +| 视觉风格 | 未确认/已确认,记录真实依据 | ... | +| 必需资产 | 待补齐/待验收/已锁定,引用真实资产版本 | ... | +| 正式分镜 | 草案不可投产/已绑定资产引用 | ... | +| 声音资产 | 未启用/待准备/已锁定(用户可选) | ... | +| 视频生成 | 未授权/已授权/进行中/已核验 | ... | + ## 剧情事实账本 | 编号 | 原文事实 | 执行者/对象 | 当前处理 | 依据 | |---|---|---|---|---| diff --git a/backend/builtin/影策短剧制作工作流/references/assets.md b/backend/builtin/影策短剧制作工作流/references/assets.md index 8535edca3..7304cf9e2 100644 --- a/backend/builtin/影策短剧制作工作流/references/assets.md +++ b/backend/builtin/影策短剧制作工作流/references/assets.md @@ -1,42 +1,91 @@ -# 资产管理 +# 资产圣经与连续性管理 -## 资产登记原则 +## 目的与硬规则 -资产服务于身份辨认、行动执行、空间连续或声音表达。只登记本轮真正出镜、被提及且需要引用的角色、场景、道具、声音和变体。没有用途的装饰、纯声音角色不自动生成肖像;未知外观只能标记为设计提案。 +本文件定义短剧跨镜头、跨片段和跨集复用的视觉/声音资产。资产不是附带清单,而是视频生成前必须建立、验收、版本化并绑定到分镜的制作依据。 -| 类型 | 稳定名称 | 剧情依据 | 保持项 | 当前状态 | 影策引用 | -|---|---|---|---|---|---| -| 角色/场景/道具/声音 | ... | 场次与事件编号 | 身份、结构、状态 | 待设计/待引用/待验收/已采用 | 真实节点或资源 ID | +允许在资产未完成时写剧情、剧本、场次拆解和草分镜;这些内容必须标注“草案,不可投产”。禁止将缺失资产用每镜临时提示词代替,也禁止把一次视频生成成功当成角色、场景或道具已经统一。 -同一资产的基础态和累积变体分开登记。记录“原状态 → 触发事件 → 新状态 → 适用范围”,不能因生成新图自动治愈旧伤、复原破损或改变持物。 +风格未确认、关键资产未验收、资产状态冲突、正式分镜缺少引用或模型无法读取引用时,停止视频生成。向用户报告具体缺项和阻塞影响,并继续完成不依赖该项的文本/资产工作。 -## 角色 +## 资产台账 -角色锚点只写用户事实和本次可见设计提案:体型、年龄感、发型、服装、识别特征、固定携带物和声音身份。动作中的表情、视线、姿态属于分镜/视频提示词,不在角色卡中提前编造。 +每个项目/剧集维护一份可回读的资产台账,并写入真实 `markdown` 或 `text` 节点。只登记本剧实际出镜、被引用或影响连续性的资产,不为无关元素扩张工作量。 -参考图必须能覆盖本次用途。读取实际图片后再登记身份,不凭文件名或提示词声称“已确认”。同一角色多张参考图用途要分开写:身份、服装、局部、动作或站位;冲突时保留差异并请求选择。 +| ID | 类型/名称 | 剧情依据 | 固定锚点 | 变体/状态 | 参考节点/资源 | 版本 | 状态 | +|---|---|---|---|---|---|---|---| +| C01 | 角色/场景/服装/道具/声音 | 场次与事件编号 | 不可随镜头漂移的特征 | 适用范围与触发事件 | 真实可读引用 | v1 | 待设计/待生成/待验收/待确认/已锁定/已废弃 | -## 场景 +资产状态含义: +- **待设计**:事实已提取,视觉/声音方案未确定。 +- **待生成**:方案已确认,参考图或音频尚未生成。 +- **待验收**:媒体已返回,尚未完成内容核对。 +- **待确认**:Agent 已检查,但仍需用户决定或用户授权范围不包含自动采用。 +- **已锁定**:引用真实、内容已验收并获用户确认,或用户明确授权 Agent 按既定标准采用;可进入制作版分镜和生成。 +- **已废弃**:不再用于新分镜,但历史节点及其依赖保留可追溯。 -场景记录空间母版、入口、通道、稳定地标、时间、天气和可发生的活动区域。局部取景不改变母版;家具移动、门窗状态、破损和光源变化才建立新的持续状态。不能凭一张漂亮的环境图推断未显示区域已经存在。 +记录设计来源:`用户事实`、`参考素材`、`设计提案`三者不可混写。提案未确认前不得当作故事事实。 -## 道具 +## 风格基底先行 -道具记录形制、材质、功能、尺寸关系、持有人、数量、开合、损耗和文字/图案处理。只有剧情功能或复用价值明确时才单独建立道具资产。参考图不应自动引入其中未提及的人物、道具或包装文案。 +在资产图和正式分镜之前,建立一份跨全剧共享的风格基底,至少记录: +- 媒介与表现方式:真人、写实三维、风格化三维、二维等;不替用户擅自决定。 +- 时代/地域/类型依据,以及与已知剧情相容的限制。 +- 色彩基调、材质语言、光影方向和整体反差。 +- 构图、镜头观感、画幅与清晰度要求。 +- 明确禁止漂移项,例如角色脸型、发色、制服主色、场景结构、武器轮廓。 -## 声音 +向用户展示可比较的方案或复述其明确要求,记录选定项和确认依据。若用户明确授权自主决定,可选一个与素材相符的方向并记录为“Agent 提案,依授权采用”。不要只用“电影感、唯美、高级”等无法核验的词。风格基底一经锁定,所有资产提示词和分镜必须继承;风格修改要创建新版本并检查受影响资产及片段。 -仅画外出现的角色登记说话人、台词出处和声线描述,不为其生成无用途肖像。真实音频只能在用户提供并且当前模型能力支持时作为参考;不编造音频资源或把音色样本设为必需前置。 +## 角色与服装 -## 影策资源与画布交接 +每个反复出现或剧情辨认关键的角色都要有稳定身份卡,至少包含:角色 ID/称呼、剧情依据、年龄感范围(未知就不填)、体型轮廓、肤色/面部锚点、发型发色、识别特征、基础服装、固定携带物、已知限制和不允许擅加的特征。 -- 只引用 `canvas_get_state` 返回的真实节点,或当前用户可访问且状态有效的资源。 -- 不编造节点 ID、资源 ID、`storageKey`、外部 URL 或参考句柄。 -- 生成参考图/资产时,使用 `canvas_apply_ops` 创建合法 `image`、`audio` 或 `video` 节点,再使用 `generate_media` 提交任务。 -- 资产节点创建、连线和媒体生成分开记录;连线是输入关系,不代表审批、采用或完成。 -- 生成前使用 `model_list`,传入本次真实参考节点 ID;模型、画幅、时长、质量和音频能力以返回值为准。 -- 成功任务写回真实媒体节点后,保留候选与已采用版本,不覆盖仍被下游引用的旧资产。 +优先生成能被后续制作复用的角色参考组:正面、侧面、背面或等价多角度转面,以及必要的面部/服装细节。具体视图组合服从当前图像模型能力;若无法在一张图稳定表达多视图,拆成多张并分别登记用途。参考图只作为设计候选,必须检查同一人物各视角是否像同一人、服装结构是否一致、是否混入多余人物或文字,再确认锁定。 -## 验收 +基础服装和剧情明确需要的服装变体分开登记。每个变体记录触发场次/事件、完整服装特征、损伤/污渍/湿润状态、适用镜头范围及继承项。换衣、受伤、淋湿或破损不得在镜头之间无因消失;未知服饰不得伪称为原文事实。 -核对身份、数量、视角/结构、空间关系、持续状态和文件可读性。工具成功不等于画面符合要求;无法查看媒体时明确“未核验”,由用户预览并决定采用。 +## 场景与环境 + +每个反复出现或影响动作连续性的场景建立空间母版:内外景、空间尺度感、入口/出口、窗、通道、固定建筑结构、重要家具、行动区域和各元素相对位置;同时登记时间、天气、主要光源及声音传播条件(剧情相关时)。 + +制作参考优先包括可用的环境全景/建立镜头参考、关键方向或多角度视图、必要的平面布局/空间关系示意。是否能生成严格意义的“建模图”取决于真实模型能力;不可承诺模型无法提供的正交图或可直接用于 3D 建模的工程文件。若只能生成概念参考,明确标注“视觉参考,非精确工程模型”。 + +逐场景登记持续状态变体,例如门开/关、家具移动、破损、光源改变、天气变化。局部镜头继承母版,摄影机换位不代表场景结构变化。没有参考覆盖的区域不得当作已确认的空间事实。 + +## 道具、武器与关键服饰细节 + +具有剧情功能、反复出现、身份辨认或动作交互的道具/武器必须有独立条目。记录形状轮廓、相对尺寸、材质、颜色、部件、文字/标记、持握/开合方式、持有人、数量、起始与结束状态、可见损伤,以及禁止变化项。 + +为关键道具准备能说明整体轮廓和重要结构的参考视图;若局部结构、握持姿势或服装附件容易漂移,补充特写参考。不得为了画面好看擅加刀枪、饰品、徽标、文字或包装信息。武器生成须遵循用户目标和平台安全规则,不因“资产维护”绕过安全限制。 + +## 声音资产(可选) + +角色音色/声音身份属于**可选资产**,不是默认必做项,也不阻塞视频生成。先询问用户是否需要跨片段保持同一音色;不需要时记录“未启用”,对白仍应沿用角色/说话人身份和已确认台词。 + +若用户选择需要且当前工具/模型支持,记录说话人 ID、用户提供或用户批准的声线描述、语速/音域等稳定参数、真实音频参考节点及使用授权。不得仿制未授权真人声音,不编造音频样本或资源引用。模型不支持音频参考时,说明限制并按用户确认的替代方案执行;声音仍需按片段验收,不能从视频成功状态推断音色一致。 + +环境声、动作声、画内对白、OS/VO 分开登记;只有实际影响本剧复用或生成时才建立声音资产。 + +## 参考图与画布资源 + +- 媒体必须实际读取/预览后才能登记为已验收;文件名、提示词、节点标题不构成视觉核验。 +- 只引用 `canvas_get_state` 中的真实节点,或当前用户可访问且状态有效的资源;禁止编造 ID、URL、`storageKey` 或参考句柄。 +- 生成资产图前,用 `model_list` 核对当前图像能力、参考限制、画幅和费用,并按权限/收费规则取得授权。 +- 图片任务成功后确认结果资源可访问,回读画布并登记真实节点、用途、版本、验收结论和状态。节点连线仅表示引用关系,不表示确认或采用。 +- 保存所有仍被分镜/视频引用的旧版;更新资产时创建新候选,不覆盖旧引用。明确新旧版本的替换范围。 +- 若 Agent 无法查看图像或无法确认多视图一致性,状态保持“待验收/待确认”,交由用户预览;禁止谎报已锁定。 + +## 验收与投产门槛 + +逐项验收: +- **角色**:各视角身份一致,年龄感/面部/发型/体型锚点稳定,服装与身份卡相符,无意外人物或错误附件。 +- **服装**:变体有明确剧情触发和适用范围;连续状态合理。 +- **场景**:空间母版内部自洽,出入口、家具和光源位置可复用,参考范围与声称范围一致。 +- **道具/武器**:轮廓、数量、尺寸关系、结构、持有人和状态与剧情一致。 +- **声音(若启用)**:参考有授权且可访问,模型能力支持,声线描述/说话人映射清楚。 +- **通用**:风格基底一致,图像清楚可读,真实引用可供目标模型使用,用户确认或授权状态可追溯。 + +**正式分镜/视频生成前检查**:所有出镜主角和重要配角、当前集主要场景、关键服装变体及剧情道具/武器均已锁定;每个正式分镜行都能引用适用资产版本;资产冲突有明确处理结论。音色仅在用户启用音色资产时纳入门槛。未通过时只可维护剧本、草分镜或补资产,不可投产。 + +锁定后,资产台账作为整剧唯一连续性依据。每次局部修订沿真实引用关系找出受影响分镜/片段,只重做必要内容,并保留不受影响的采用结果。 diff --git a/backend/builtin/影策短剧制作工作流/references/canvas-execution.md b/backend/builtin/影策短剧制作工作流/references/canvas-execution.md index f54718eb8..558c4b0da 100644 --- a/backend/builtin/影策短剧制作工作流/references/canvas-execution.md +++ b/backend/builtin/影策短剧制作工作流/references/canvas-execution.md @@ -27,17 +27,20 @@ ## 标准执行链 -1. `canvas_get_state`,确认当前项目、已有内容、快照和用户范围。 -2. `canvas_list_node_types`,确认 `text`、`markdown`、`script`、`image`、`video`、`audio` 等真实能力。 -3. 复用或用 `canvas_apply_ops` 创建输入文稿节点;不重复创建已有正文。 -4. 对多镜头制作调用 `canvas_create_storyboard`,每一行填入已确认事件、时长、画面、对白、运动、声音和连续性字段。 -5. 创建后用 `canvas_read_storyboard` 回读,核对行数、顺序、时长和真实行 ID。 -6. 需要局部修改时再次读取最新快照,再调用 `canvas_edit_storyboard`;冲突则重新读取,不覆盖用户新改内容。 -7. 为每个生成段准备合法媒体节点和引用边;`canvas_apply_ops` 不提交生成、不收费。 -8. 用 `model_list` 传入真实媒体参考节点,选择返回的 `logicalModelId` 或完整渠道选择,不猜模型名。 -9. 得到用户对具体对象、数量、模型和费用范围的授权后调用 `generate_media`。`prompt` 中只引用真实 `@图片1`、`@视频1`、`@音频1` 顺序。 -10. 用 `task_get` 或真实事件流读取任务状态;成功后确认结果资源可访问并回写节点状态。 -11. 对独立节点调用 `canvas_arrange_nodes`,再输出报告和待办。 +1. `canvas_get_state`,确认当前项目、已有内容、快照、关联任务和当前采用版本。 +2. `canvas_list_node_types`,确认文本、分镜及媒体节点类型和字段。 +3. 写入/复用输入文稿与剧情事实账本。剧本阶段可产出草分镜,但显式标记“草案,不可投产”。 +4. 先确认共享风格基底,再建立角色、服装、场景、关键道具/武器资产台账;按 `assets.md` 生成/登记参考,回读真实资源并完成验收、确认和版本锁定。音色参考只有用户选择时才准备。 +5. 检查投产门槛:必需资产均已锁定、无未解决冲突,风格基底已确认。未通过则停止于资产/草案阶段,不创建制作版分镜,不提交视频任务。 +6. 通过门槛后调用 `canvas_create_storyboard` 创建制作版分镜,行内写入已确认事件、时长、场景状态、服装/道具状态、对白/声音、连续性字段和真实资产引用。 +7. 创建后 `canvas_read_storyboard` 回读,核对行数、顺序、时长、资产引用及真实行 ID。修改时再次读取最新快照,再调用 `canvas_edit_storyboard`;冲突则重新读取,不覆盖用户新改内容。 +8. 为每个已批准生成段准备合法媒体节点和引用边;`canvas_apply_ops` 不提交生成、不收费。检查媒体模型实际能够读取这些锁定资产。 +9. 用 `model_list` 传入真实参考节点,查询模型/渠道、参考数量、时长、画幅、音频能力和价格。按实际能力选择,不猜模型名;参考超限时制定经用户确认的分批方案,不静默丢弃身份锚点。 +10. 向用户说明本批次对象、数量、模型、参数、参考资产和费用范围,获得具体授权后调用 `generate_media`。提示词只使用真实 `@图片1`、`@视频1`、`@音频1` 顺序。 +11. 用 `task_get` 或真实事件流核验状态;成功后确认结果资源可访问,并逐项检查剧情、角色、服饰、场景、道具和声音连续性,再回写节点/采用状态。 +12. 独立节点用 `canvas_arrange_nodes` 整理,输出阶段、阻塞项、真实节点/任务和未核验范围。 + +草分镜不是视频生成授权,也不是资产门槛豁免。即使用户催促直接生成,仍要说明缺失资产会造成的跨镜/跨集一致性风险;只有用户明确要求带风险继续且该动作符合系统权限与平台规则时,才按明确范围执行,并将结果标记为未锁定资产条件下的试片,不得宣称可连续交付。 ## 禁止事项 diff --git a/backend/builtin/影策短剧制作工作流/references/state-and-delivery.md b/backend/builtin/影策短剧制作工作流/references/state-and-delivery.md index d37c48ce1..36c033353 100644 --- a/backend/builtin/影策短剧制作工作流/references/state-and-delivery.md +++ b/backend/builtin/影策短剧制作工作流/references/state-and-delivery.md @@ -9,8 +9,12 @@ 目标与范围: 当前项目/画布: +当前阶段:剧情与剧本 | 风格确认 | 资产建立 | 资产验收 | 正式分镜 | 视频生成 | 交付 当前有效剧本与分镜快照: -阶段与授权范围: +风格基底版本/确认依据: +必需资产锁定状态: +草分镜/制作版分镜状态: +阶段授权范围: ## 成果 | 标识 | 类型 | 版本 | 真实节点/任务 | 状态 | 依据 | diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 803c43ea5..08c996c6a 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -456,6 +456,7 @@ func migrations() []tableMigration { migrateTable[model.CloudAgentMessageRecord]("cloud_agent_message_records"), migrateTable[model.CloudAgentCanvasMutation]("cloud_agent_canvas_mutations"), migrateTable[model.CloudAgentResourceLease]("cloud_agent_resource_leases"), + migrateTable[model.CloudAgentPiSession]("cloud_agent_pi_sessions"), migrateTable[model.CloudAgentGeminiCache]("cloud_agent_gemini_caches"), migrateTable[model.AgentProfile]("agent_profiles"), migrateTable[model.AgentLesson]("agent_lessons"), diff --git a/backend/internal/agent/runtime/runtime.go b/backend/internal/agent/runtime/runtime.go new file mode 100644 index 000000000..4ad757ced --- /dev/null +++ b/backend/internal/agent/runtime/runtime.go @@ -0,0 +1,191 @@ +package runtime + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const requestLimit = 32 << 20 +const lineLimit = 4 << 20 + +type Bridge struct { + Model func(context.Context, map[string]json.RawMessage) (any, error) + Tool func(context.Context, map[string]json.RawMessage) (any, error) + Event func(context.Context, map[string]json.RawMessage) (any, error) +} + +type ProcessRequest struct { + BridgeURL string `json:"bridgeURL"` + BridgeToken string `json:"bridgeToken"` + SessionFile string `json:"sessionFile,omitempty"` + SessionJSONL string `json:"sessionJSONL,omitempty"` + SessionDir string `json:"sessionDir,omitempty"` + SessionID string `json:"sessionId,omitempty"` + UserID string `json:"userId,omitempty"` + CanvasID string `json:"canvasId,omitempty"` + RunID string `json:"runId,omitempty"` + CWD string `json:"cwd"` + AgentDir string `json:"agentDir"` + Prompt string `json:"prompt"` + SystemPrompt string `json:"systemPrompt"` + SkillPaths []string `json:"skillPaths,omitempty"` + EnabledSkills []map[string]any `json:"enabledSkills,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Memory map[string]any `json:"memory,omitempty"` + Canvas map[string]any `json:"canvas,omitempty"` + Features map[string]any `json:"features,omitempty"` + Tools []map[string]any `json:"tools"` + Compaction map[string]any `json:"compaction,omitempty"` + Permissions map[string]any `json:"permissions,omitempty"` + Model map[string]any `json:"model"` +} + +func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error { + if bridge.Model == nil || bridge.Tool == nil || bridge.Event == nil { + return errors.New("Agent bridge handlers are incomplete") + } + runtimeDir, err := RuntimeDir() + if err != nil { + return err + } + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return fmt.Errorf("create Agent bridge credential: %w", err) + } + token := hex.EncodeToString(tokenBytes) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("listen for Agent bridge: %w", err) + } + defer listener.Close() + mux := http.NewServeMux() + mux.HandleFunc("POST /model", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Model) }) + mux.HandleFunc("POST /tool", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Tool) }) + mux.HandleFunc("POST /event", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Event) }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 2 * time.Minute} + serverDone := make(chan error, 1) + go func() { serverDone <- server.Serve(listener) }() + defer func() { _ = server.Shutdown(context.Background()) }() + + request.BridgeURL = "http://" + listener.Addr().String() + request.BridgeToken = token + payload, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode Agent runtime request: %w", err) + } + cmd := exec.CommandContext(ctx, "node", filepath.Join(runtimeDir, "agent-runtime.mjs")) + cmd.Dir = runtimeDir + cmd.Stdin = strings.NewReader(string(payload)) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("open Agent runtime output: %w", err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start Agent runtime: %w", err) + } + stdoutErr := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), lineLimit) + for scanner.Scan() { + var event struct { + Event string `json:"event"` + Message string `json:"message"` + } + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + stdoutErr <- fmt.Errorf("decode Agent runtime event: %w", err) + return + } + if event.Event == "runtime_error" || event.Event == "bridge_error" { + stdoutErr <- errors.New(firstNonEmpty(event.Message, "Agent runtime failed")) + return + } + } + if err := scanner.Err(); err != nil { + stdoutErr <- fmt.Errorf("read Agent runtime output: %w", err) + return + } + stdoutErr <- nil + }() + waitErr := cmd.Wait() + lineErr := <-stdoutErr + if lineErr != nil { + return lineErr + } + if waitErr != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("Agent runtime exited: %w", waitErr) + } + return nil +} + +func BridgeCall(w http.ResponseWriter, r *http.Request, expectedToken string, handler func(context.Context, map[string]json.RawMessage) (any, error)) { + if r.RemoteAddr == "" || !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") { + http.Error(w, `{"error":"loopback bridge only"}`, http.StatusForbidden) + return + } + if r.Header.Get("Authorization") != "Bearer "+expectedToken { + http.Error(w, `{"error":"unauthorized bridge"}`, http.StatusUnauthorized) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, requestLimit+1)) + if err != nil || len(body) > requestLimit { + http.Error(w, `{"error":"bridge payload too large or unreadable"}`, http.StatusRequestEntityTooLarge) + return + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, `{"error":"invalid bridge payload"}`, http.StatusBadRequest) + return + } + result, err := handler(r.Context(), payload) + w.Header().Set("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + _ = json.NewEncoder(w).Encode(result) +} + +func RuntimeDir() (string, error) { + candidates := []string{os.Getenv("CANVAS_PI_RUNTIME_DIR"), "/app/backend/agent-runtime/pi"} + if _, source, _, ok := runtime.Caller(0); ok { + candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(source), "../../../agent-runtime/pi"))) + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if info, err := os.Stat(filepath.Join(candidate, "agent-runtime.mjs")); err == nil && !info.IsDir() { + return candidate, nil + } + } + return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index ab24d6271..1262d35d2 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -371,7 +371,11 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare if state.Fingerprint == "" || state.Fingerprint != fingerprint { return nil, kernel.NewAppError(409, "幂等键已用于不同请求,请使用新的幂等键") } - return s.CloudAgentRun(userID, existing.ID) + run, runErr := s.CloudAgentRun(userID, existing.ID) + if runErr == nil && !cloudAgentRunTerminal(run.Status) { + s.startCloudAgentPi(userID, existing.ID) + } + return run, runErr } else { var appErr *AppError if !errors.As(lookupErr, &appErr) || appErr.Status != 404 { @@ -391,13 +395,7 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare if parent.ProjectID != req.CanvasID { return nil, kernel.Forbidden("不能跨画布追加 Agent 消息") } - if parent.Status == model.TaskStatusQueued || parent.Status == model.TaskStatusRunning { - return nil, kernel.NewAppError(409, "上一轮仍在执行,请等待结束") - } superseded := s.cloudAgentParentCanBeSuperseded(userID, parentID) - if err := s.advanceCloudAgentByID(userID, parentID); err != nil { - return nil, err - } // 续轮收束要读上一轮**全部**事件(运行详情默认只返回尾部一窗):长会话一旦被截断, // 新轮就看不到上一轮改过哪些节点、提交过哪些任务,表现为"忘了自己做过什么"。 parentRun, err := s.CloudAgentRun(userID, parentID, CloudAgentRunViewOptions{EventLimit: cloudAgentContinuationEventLimit}) @@ -413,6 +411,9 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare if err != nil { return nil, err } + if !cloudAgentRunTerminal(parentExecution.Status) || parentExecution.CleanupPending { + return nil, kernel.NewAppError(409, "上一轮仍在执行,请等待结束") + } parentState, err := cloudAgentDecode(parentExecution) if err != nil { return nil, WrapAppError(409, "上一轮 Agent 历史记录不完整,无法继续对话;请新建对话", err) @@ -502,8 +503,14 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare input := map[string]any{"mode": "text", "prompt": req.Prompt, "textHistory": history, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(policy.ReasoningMode)}, "cloudAgent": state, "agentRequests": map[string]any{"canonical": canonical}, "config": map[string]any{"channelId": req.ChannelID, "channelModelKey": req.ChannelModelKey, "model": firstNonEmpty(req.ChannelModelKey, req.Model), "systemPrompt": system}} + input["piSessionJSONL"] = "" + if parentID != "" { + if session, sessionErr := s.repo.CloudAgentPiSession(userID, parentID); sessionErr == nil { + input["piSessionJSONL"] = session.SessionJSONL + } + } task, err := s.CreateTask(userID, CreateTaskRequest{ProjectID: req.CanvasID, Type: "canvas_text", Operation: cloudAgentOperation, Prompt: req.Prompt, Model: req.Model, LogicalModelID: req.LogicalModelID, Input: input, - admission: &taskAdmission{ID: id, MaxCharge: int64(math.Floor(req.Budget.MaxCredits * float64(CreditScale)))}}) + admission: &taskAdmission{ID: id, MaxCharge: int64(math.Floor(req.Budget.MaxCredits * float64(CreditScale))), NonBillable: true}}) if err != nil { // A concurrent identical request may have won the transaction. Never // replace its result or reserve credits a second time. @@ -511,11 +518,20 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare if stored.Fingerprint == "" || stored.Fingerprint != fingerprint { return nil, kernel.NewAppError(409, "幂等键已用于不同请求") } - return s.CloudAgentRun(userID, existing.ID) + run, runErr := s.CloudAgentRun(userID, existing.ID) + if runErr == nil && !cloudAgentRunTerminal(run.Status) { + s.startCloudAgentPi(userID, existing.ID) + } + return run, runErr } return nil, err } - return s.CloudAgentRun(userID, task.ID) + run, err := s.CloudAgentRun(userID, task.ID) + if err != nil { + return nil, err + } + s.startCloudAgentPi(userID, task.ID) + return run, nil } // 旧运行记录没有单独保存历史;只从模型请求中当前用户消息之前的严格交替前缀恢复。 diff --git a/backend/internal/app/cloud_agent_approval_settings_test.go b/backend/internal/app/cloud_agent_approval_settings_test.go index e6d3d0e20..3f2e7064e 100644 --- a/backend/internal/app/cloud_agent_approval_settings_test.go +++ b/backend/internal/app/cloud_agent_approval_settings_test.go @@ -205,15 +205,21 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) { } var before int64 db.Model(&model.BillingOrder{}).Count(&before) - for range 2 { - if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &settings); err != nil { - t.Fatal(err) - } + if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &settings); err != nil { + t.Fatal(err) } - var orders int64 - db.Model(&model.BillingOrder{}).Count(&orders) - if orders != before { - t.Fatal("editing approval charged before task submission") + var afterFirstApproval int64 + db.Model(&model.BillingOrder{}).Count(&afterFirstApproval) + if afterFirstApproval <= before { + t.Fatal("approval did not submit the approved media task") + } + if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &settings); err != nil { + t.Fatal(err) + } + var afterRetry int64 + db.Model(&model.BillingOrder{}).Count(&afterRetry) + if afterRetry != afterFirstApproval { + t.Fatalf("idempotent approval created another billing order: first=%d retry=%d", afterFirstApproval, afterRetry) } changed := settings changed.Size = "1:1" diff --git a/backend/internal/app/cloud_agent_batch_table.go b/backend/internal/app/cloud_agent_batch_table.go index ab84ba3b5..cc49e7533 100644 --- a/backend/internal/app/cloud_agent_batch_table.go +++ b/backend/internal/app/cloud_agent_batch_table.go @@ -209,8 +209,8 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas return nil, err } beforeHash := cloudAgentCanvasHash(doc) - if beforeHash != args.SnapshotHash { - return nil, creationConflict("画布已变化,本次未写入;请重新读取并重新申请审批") + if !cloudAgentNodeSnapshotMatches(doc, args.NodeID, args.SnapshotHash) { + return nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "这个批量创作表在你读取之后被修改过,本次未写入;请重新读取后再改") } node, table, rows, columns, err := batchTableNodeFromDocument(doc, args.NodeID) if err != nil { @@ -405,10 +405,10 @@ func applyCloudAgentBatchTableMutation(repo *repository.Repository, userID, canv return nil, err } } - return map[string]any{"canvasId": canvasID, "nodeId": plan.Preview.Items[0].NodeID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "summary": plan.Preview.Description, "preview": plan.Preview}, nil + return map[string]any{"canvasId": canvasID, "nodeId": plan.Preview.Items[0].NodeID, "snapshotHash": cloudAgentNodeHash(plan.Document, plan.Preview.Items[0].NodeID), "summary": plan.Preview.Description, "preview": plan.Preview}, nil } -func cloudAgentBatchTableReadResult(view any, nodeID string) (map[string]any, error) { +func cloudAgentBatchTableReadResult(view any, nodeID, nodeHash string) (map[string]any, error) { state, ok := view.(map[string]any) if !ok { return nil, BadAuthRequest("批量创作表读取结果无效") @@ -431,5 +431,5 @@ func cloudAgentBatchTableReadResult(view any, nodeID string) (map[string]any, er row["rowId"] = row["id"] } } - return map[string]any{"nodeId": nodeID, "title": node["title"], "snapshotHash": state["snapshotHash"], "batchTable": table}, nil + return map[string]any{"nodeId": nodeID, "title": node["title"], "snapshotHash": nodeHash, "batchTable": table}, nil } diff --git a/backend/internal/app/cloud_agent_batch_table_test.go b/backend/internal/app/cloud_agent_batch_table_test.go index 09a3b8b3d..2b588ee62 100644 --- a/backend/internal/app/cloud_agent_batch_table_test.go +++ b/backend/internal/app/cloud_agent_batch_table_test.go @@ -321,7 +321,7 @@ func TestCloudAgentBatchTableRejectsUnsafeAndStaleEdits(t *testing.T) { stale := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "stale-row", map[string]any{ "snapshotHash": "stale", "nodeId": "batch-1", "action": "set_concurrency", "concurrency": 5, }) - if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "画布已变化") { + if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "被修改过") { t.Fatalf("stale snapshot was not rejected: %v", err) } } diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index b0d1b57aa..63da86644 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -28,6 +28,31 @@ var cloudAgentStructuredProjectors = map[string]cloudAgentStructuredProjector{ }, } +// cloudAgentNodeHash 是单个节点内容的版本号。分镜/批量表的读写只作用在一个节点上, +// 用它做并发校验:用户删改其它节点不会让 Agent 对这个节点的修改失效; +// 这个节点本身被改过(或被删掉)时才拒绝写入。 +func cloudAgentNodeHash(doc map[string]any, nodeID string) string { + for _, node := range creationMaps(doc["nodes"]) { + if stringValue(node["id"]) == nodeID { + return creationHash(node) + } + } + return "" +} + +// cloudAgentNodeSnapshotMatches 接受两种版本号:旧的整画布哈希(画布完全未变)或 +// 目标节点哈希(只有目标节点未变)。前者兼容已经发出、带整画布哈希的调用。 +func cloudAgentNodeSnapshotMatches(doc map[string]any, nodeID, snapshotHash string) bool { + if snapshotHash == "" { + return false + } + if snapshotHash == cloudAgentCanvasHash(doc) { + return true + } + node := cloudAgentNodeHash(doc, nodeID) + return node != "" && node == snapshotHash +} + // Viewport autosaves must not invalidate approved content; node edits still do. func cloudAgentCanvasHash(doc map[string]any) string { content := make(map[string]any, len(doc)) diff --git a/backend/internal/app/cloud_agent_context_budget.go b/backend/internal/app/cloud_agent_context_budget.go index 0e96e5b42..73e10177a 100644 --- a/backend/internal/app/cloud_agent_context_budget.go +++ b/backend/internal/app/cloud_agent_context_budget.go @@ -50,12 +50,14 @@ func cloudAgentContextBudgetFor(contextWindow, maxOutput int, source string) clo if inputBudget < 1_024 { inputBudget = max(1_024, contextWindow/2) } + compactAt := min(contextWindow*80/100, inputBudget) + compactAt = max(1_024, compactAt) return cloudAgentContextBudget{ ContextWindowTokens: contextWindow, MaxOutputTokens: maxOutput, OverheadTokens: overhead, InputBudgetTokens: inputBudget, - CompactAtTokens: max(1_024, inputBudget*85/100), + CompactAtTokens: compactAt, Source: source, Configured: configured, } diff --git a/backend/internal/app/cloud_agent_context_compaction.go b/backend/internal/app/cloud_agent_context_compaction.go index 805660ef1..ff1f4c957 100644 --- a/backend/internal/app/cloud_agent_context_compaction.go +++ b/backend/internal/app/cloud_agent_context_compaction.go @@ -18,9 +18,8 @@ import ( const cloudAgentContextCompactionOperation = "cloud_agent_context_compaction" const ( - // cloudAgentMaxCompactionsPerRun 限制同一轮最多压几次:压完仍然超阈值时不能无限暂停 - // (检查点已经只剩少量历史,再压也不会更小),用完次数就回到上游的原有判死路径。 - cloudAgentMaxCompactionsPerRun = 4 + // 压缩次数是异常循环护栏,不应成为正常长会话的闸门。 + cloudAgentMaxCompactionsPerRun = 64 // cloudAgentContextKeepPairs 是压缩后原样保留的最近对话对数:检查点负责事实,最近两对 // 负责"用户最后说了什么、模型最后答了什么"的语感与指代。 cloudAgentContextKeepPairs = 2 @@ -48,7 +47,7 @@ type cloudAgentCompactionReading struct { // cloudAgentCompactionReadingFor 把"下一步预计输入 token"接到上游已有的预算算式上。 // -// 阈值不在这里另定:上游 cloudAgentContextBudgetFor 已经把压缩线算成输入预算的 85% +// 阈值不在这里另定:上游 cloudAgentContextBudgetFor 已经把压缩线算成模型窗口的 80% // (CompactAtTokens),就地正文卸载用的就是它;语义压缩必须共用同一条线,否则会出现 // "显示还没到线、后台已经开始压"。 // @@ -66,7 +65,7 @@ func cloudAgentCompactionReadingFor(budget cloudAgentContextBudget, projectedTok BudgetSource: budget.Source, } if budget.InputBudgetTokens > 0 { - reading.PressureRatio = math.Round(float64(projectedTokens)/float64(budget.InputBudgetTokens)*10000) / 10000 + reading.PressureRatio = math.Round(float64(projectedTokens)/float64(budget.ContextWindowTokens)*10000) / 10000 } if !budget.Configured || budget.Source == "" || budget.Source == "default" { return reading, false diff --git a/backend/internal/app/cloud_agent_context_compaction_test.go b/backend/internal/app/cloud_agent_context_compaction_test.go index 48101c4b3..1d133afea 100644 --- a/backend/internal/app/cloud_agent_context_compaction_test.go +++ b/backend/internal/app/cloud_agent_context_compaction_test.go @@ -116,8 +116,9 @@ func TestCloudAgentCompactionRequestsWhenTokenLineReached(t *testing.T) { if payload["basis"] != "tokens" || payload["compactAtTokens"] != float64(budget.CompactAtTokens) { t.Fatalf("触发读数 = %+v", payload) } - if ratio, ok := payload["pressureRatio"].(float64); !ok || ratio < 0.84 { - t.Fatalf("压力读数 = %+v", payload["pressureRatio"]) + wantPressureRatio := float64(budget.CompactAtTokens) / float64(budget.ContextWindowTokens) + if ratio, ok := payload["pressureRatio"].(float64); !ok || ratio < wantPressureRatio-0.001 || ratio > wantPressureRatio+0.001 { + t.Fatalf("压力读数 = %+v, want approximately %.3f", payload["pressureRatio"], wantPressureRatio) } } diff --git a/backend/internal/app/cloud_agent_context_pressure.go b/backend/internal/app/cloud_agent_context_pressure.go index 474f6943f..ddd7aa904 100644 --- a/backend/internal/app/cloud_agent_context_pressure.go +++ b/backend/internal/app/cloud_agent_context_pressure.go @@ -25,7 +25,7 @@ type cloudAgentContextPressure struct { OverheadTokens int `json:"overheadTokens,omitempty"` InputBudgetTokens int `json:"inputBudgetTokens,omitempty"` BudgetSource string `json:"budgetSource,omitempty"` - // CompactAtTokens 是上游既有的"可重读正文就地裁剪"触发线(输入预算的 85%,见 + // CompactAtTokens 是统一的上下文压缩线(模型窗口的 80%,且不超过输入准入预算,见 // compactCloudAgentContext)。它只是给读数一个参照刻度,不是本读数触发的动作。 CompactAtTokens int `json:"compactAtTokens,omitempty"` } @@ -117,8 +117,8 @@ func cloudAgentContextPressurePayload(pressure cloudAgentContextPressure, state payload["normalizedInputTokens"] = state.TokenAnchor.InputTokens } payload["projectedNextInputTokens"] = projectedTokens - if pressure.UsableInputTokens > 0 && tokenSource == "provider" { - payload["projectedPressureRatio"] = math.Min(9.99, float64(projectedTokens)/float64(pressure.UsableInputTokens)) + if pressure.ContextWindowTokens > 0 && tokenSource == "provider" { + payload["projectedPressureRatio"] = math.Min(9.99, float64(projectedTokens)/float64(pressure.ContextWindowTokens)) } payload["breakdown"] = cloudAgentContextBreakdownPayload(state, actual...) return payload @@ -249,8 +249,8 @@ func (s *Service) cloudAgentContextPressure(canonical canonicalAgentRequest, pro pressure.BudgetSource = budget.Source // 展示口径与请求准入判据共用同一份输入预算:否则会出现"界面 79%、后台按 82% 拒绝"。 pressure.UsableInputTokens = budget.InputBudgetTokens - if pressure.UsableInputTokens > 0 { - pressure.PressureRatio = math.Min(9.99, float64(pressure.EstimatedInputTokens)/float64(pressure.UsableInputTokens)) + if pressure.ContextWindowTokens > 0 { + pressure.PressureRatio = math.Min(9.99, float64(pressure.EstimatedInputTokens)/float64(pressure.ContextWindowTokens)) } return pressure } diff --git a/backend/internal/app/cloud_agent_contract_continuation_test.go b/backend/internal/app/cloud_agent_contract_continuation_test.go index 031b3dd9a..13d7c6a9a 100644 --- a/backend/internal/app/cloud_agent_contract_continuation_test.go +++ b/backend/internal/app/cloud_agent_contract_continuation_test.go @@ -26,6 +26,9 @@ func TestCloudAgentContinuesAfterContractChange(t *testing.T) { Updates(map[string]any{"status": model.TaskStatusFailed, "error": "mock failure"}).Error; err != nil { t.Fatal(err) } + if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", root.ID).Update("status", "failed").Error; err != nil { + t.Fatal(err) + } // 在该轮快照里制造一次「合同变更」:把能力集哈希改掉。 execution, err := s.repo.CloudAgent("user", root.ID) if err != nil { diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 85315799c..9ebd83ce0 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -854,7 +854,7 @@ func completeCloudAgentMediaNode(repo *repository.Repository, userID, canvasID, } meta["taskStatus"] = string(task.Status) meta["status"] = "error" - meta["errorDetails"] = "媒体任务" + string(task.Status) + ":" + cloudAgentSafeMediaTaskError(task) + meta["errorDetails"] = cloudAgentSafeMediaTaskError(task) if task.Status == model.TaskStatusSucceeded { id, _ := taskOutputResource(task.ResultJSON, task.Type) resource, e := repo.ResourceForUser(userID, id) diff --git a/backend/internal/app/cloud_agent_media_test.go b/backend/internal/app/cloud_agent_media_test.go index 9212c198f..243111829 100644 --- a/backend/internal/app/cloud_agent_media_test.go +++ b/backend/internal/app/cloud_agent_media_test.go @@ -312,17 +312,21 @@ func TestCloudAgentMediaRejectAndInvalidModelDoNotCreateTasks(t *testing.T) { if scenario == "reject" { decision = "reject" } - if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil { - t.Fatal(err) - } if scenario == "stale-after-approval" { + // Approval now executes its prepared tool synchronously before + // returning; make the canvas stale before that execution boundary. if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", `{"nodes":[],"connections":[]}`).Error; err != nil { t.Fatal(err) } } - if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil { t.Fatal(err) } + if decision == "reject" { + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + } run, _ = s.repo.CloudAgent("user", run.ID) state, _ = cloudAgentDecode(run) var count int64 diff --git a/backend/internal/app/cloud_agent_node_scoped_snapshot_test.go b/backend/internal/app/cloud_agent_node_scoped_snapshot_test.go new file mode 100644 index 000000000..1c4d7bbd5 --- /dev/null +++ b/backend/internal/app/cloud_agent_node_scoped_snapshot_test.go @@ -0,0 +1,92 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +// 线上复现:Agent 读完分镜准备改第三镜,用户在画布上删掉一个无关节点, +// Agent 的修改被整画布哈希判为"画布已变化",整轮失败。 +// 分镜/批量表的版本只看目标节点本身:无关节点的增删改不影响;目标节点被改才拒绝。 +func TestCloudAgentStoryboardEditSurvivesUnrelatedCanvasChanges(t *testing.T) { + s, canvas := cloudAgentStoryboardFixture(t) + rows := createCloudAgentStoryboardForTest(t, s, canvas) + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + + // 用户在画布上加一个无关节点。 + stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + doc, _ := creationDocument(stored.PayloadJSON) + doc["nodes"] = append(creationMaps(doc["nodes"]), map[string]any{"id": "scratch", "type": "text", "metadata": map[string]any{"content": "随手记"}}) + raw, _ := json.Marshal(doc) + before := stored.PayloadJSON + stored.PayloadJSON = string(raw) + if err := saveCreationCanvasWithHistory(s.repo, stored, before); err != nil { + t.Fatal(err) + } + + // Agent 用生产读工具读取分镜,拿到的版本号。 + state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: canvas.ID}} + read := cloudAgentCall{ID: "read"} + read.Function.Name = "canvas_read_storyboard" + read.Function.Arguments = `{"nodeId":"storyboard-1"}` + view, err := cloudAgentReadTool(s.repo, "user", state, read) + if err != nil { + t.Fatal(err) + } + readHash, _ := view.(map[string]any)["snapshotHash"].(string) + if readHash == "" { + t.Fatal("storyboard read returned no snapshotHash") + } + + // 用户删掉那个无关节点(与截图里的操作一致)。 + stored, _ = s.repo.CanvasProjectForUser("user", canvas.ID) + doc, _ = creationDocument(stored.PayloadJSON) + kept := []map[string]any{} + for _, node := range creationMaps(doc["nodes"]) { + if stringValue(node["id"]) != "scratch" { + kept = append(kept, node) + } + } + doc["nodes"] = kept + raw, _ = json.Marshal(doc) + before = stored.PayloadJSON + stored.PayloadJSON = string(raw) + if err := saveCreationCanvasWithHistory(s.repo, stored, before); err != nil { + t.Fatal(err) + } + + edit := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "edit-after-unrelated-delete", map[string]any{ + "snapshotHash": readHash, "nodeId": "storyboard-1", "action": "update", "rowId": stringValue(rows[0]["id"]), + "patch": map[string]any{"dialogue": "差两文!"}, + }) + result, err := applyCloudAgentStoryboardMutation(s.repo, "user", canvas.ID, edit, policy) + if err != nil { + t.Fatalf("unrelated node deletion must not block the storyboard edit: %v", err) + } + after, _ := s.repo.CanvasProjectForUser("user", canvas.ID) + if !strings.Contains(after.PayloadJSON, "差两文!") { + t.Fatal("edit was not written") + } + + // 返回的新版本号可以直接用于下一次修改(同一轮连续改多镜)。 + nextHash, _ := result.(map[string]any)["snapshotHash"].(string) + next := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "next-edit", map[string]any{ + "snapshotHash": nextHash, "nodeId": "storyboard-1", "action": "update", "rowId": stringValue(rows[1]["id"]), + "patch": map[string]any{"dialogue": "拍铜板报价"}, + }) + if _, err := applyCloudAgentStoryboardMutation(s.repo, "user", canvas.ID, next, policy); err != nil { + t.Fatalf("follow-up edit with returned version failed: %v", err) + } + + // 目标分镜本身被别处改过:旧版本号必须被拒绝,不能覆盖用户的修改。 + if _, err := applyCloudAgentStoryboardMutation(s.repo, "user", canvas.ID, edit, policy); err == nil || !strings.Contains(err.Error(), "被修改过") { + t.Fatalf("stale version of the same storyboard must be rejected: %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_engines.go b/backend/internal/app/cloud_agent_pi_canvas_engines.go new file mode 100644 index 000000000..f9641f624 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_engines.go @@ -0,0 +1,738 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "sort" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// Canvas Layout Engine - 自动布局引擎 + +type CanvasLayoutEngine struct { + service *Service +} + +func NewCanvasLayoutEngine(service *Service) *CanvasLayoutEngine { + return &CanvasLayoutEngine{service: service} +} + +// ApplyLayout 应用布局算法 +func (e *CanvasLayoutEngine) ApplyLayout(algorithm, direction string, spacing float64, nodes []model.CanvasNode) (map[string]*model.CanvasPosition, error) { + if algorithm == "auto" { + algorithm = e.detectBestAlgorithm(nodes) + } + + switch algorithm { + case "tree": + return e.applyTreeLayout(nodes, direction, spacing) + case "grid": + return e.applyGridLayout(nodes, spacing) + case "force": + return e.applyForceLayout(nodes, spacing) + case "circular": + return e.applyCircularLayout(nodes, spacing) + case "hierarchical": + return e.applyHierarchicalLayout(nodes, direction, spacing) + default: + return nil, fmt.Errorf("unsupported layout algorithm: %s", algorithm) + } +} + +// detectBestAlgorithm 检测最佳布局算法 +func (e *CanvasLayoutEngine) detectBestAlgorithm(nodes []model.CanvasNode) string { + // 检查父子关系比例 + withParent := 0 + for _, node := range nodes { + if node.ParentID != nil { + withParent++ + } + } + + if len(nodes) > 0 && float64(withParent)/float64(len(nodes)) > 0.6 { + return "tree" + } + + if len(nodes) > 20 { + return "force" + } + + return "grid" +} + +// applyTreeLayout 树形布局 +func (e *CanvasLayoutEngine) applyTreeLayout(nodes []model.CanvasNode, direction string, spacing float64) (map[string]*model.CanvasPosition, error) { + positions := make(map[string]*model.CanvasPosition) + + // 找出根节点 + roots := make([]model.CanvasNode, 0) + nodeMap := make(map[string]model.CanvasNode) + for _, node := range nodes { + nodeMap[node.ID] = node + if node.ParentID == nil { + roots = append(roots, node) + } + } + + if len(roots) == 0 { + return nil, fmt.Errorf("no root nodes found") + } + + // 为每个根节点构建子树 + currentY := 0.0 + for _, root := range roots { + if direction == "horizontal" { + e.layoutTreeHorizontal(root, nodeMap, positions, 0, ¤tY, spacing) + } else { + e.layoutTreeVertical(root, nodeMap, positions, 0, ¤tY, spacing) + } + currentY += spacing * 2 + } + + return positions, nil +} + +// layoutTreeVertical 垂直树形布局 +func (e *CanvasLayoutEngine) layoutTreeVertical(node model.CanvasNode, nodeMap map[string]model.CanvasNode, positions map[string]*model.CanvasPosition, depth int, currentX *float64, spacing float64) { + x := *currentX + y := float64(depth) * spacing * 2 + + positions[node.ID] = &model.CanvasPosition{X: x, Y: y} + + // 布局子节点 + if len(node.Children) > 0 { + childX := x + for _, childID := range node.Children { + if child, ok := nodeMap[childID]; ok { + e.layoutTreeVertical(child, nodeMap, positions, depth+1, &childX, spacing) + childX += spacing + } + } + } else { + *currentX += spacing + } +} + +// layoutTreeHorizontal 水平树形布局 +func (e *CanvasLayoutEngine) layoutTreeHorizontal(node model.CanvasNode, nodeMap map[string]model.CanvasNode, positions map[string]*model.CanvasPosition, depth int, currentY *float64, spacing float64) { + x := float64(depth) * spacing * 2 + y := *currentY + + positions[node.ID] = &model.CanvasPosition{X: x, Y: y} + + // 布局子节点 + if len(node.Children) > 0 { + childY := y + for _, childID := range node.Children { + if child, ok := nodeMap[childID]; ok { + e.layoutTreeHorizontal(child, nodeMap, positions, depth+1, &childY, spacing) + childY += spacing + } + } + } else { + *currentY += spacing + } +} + +// applyGridLayout 网格布局 +func (e *CanvasLayoutEngine) applyGridLayout(nodes []model.CanvasNode, spacing float64) (map[string]*model.CanvasPosition, error) { + positions := make(map[string]*model.CanvasPosition) + + // 计算网格维度 + cols := int(math.Ceil(math.Sqrt(float64(len(nodes))))) + if cols == 0 { + cols = 1 + } + + for i, node := range nodes { + row := i / cols + col := i % cols + + positions[node.ID] = &model.CanvasPosition{ + X: float64(col) * spacing, + Y: float64(row) * spacing, + } + } + + return positions, nil +} + +// applyForceLayout 力导向布局(简化版) +func (e *CanvasLayoutEngine) applyForceLayout(nodes []model.CanvasNode, spacing float64) (map[string]*model.CanvasPosition, error) { + positions := make(map[string]*model.CanvasPosition) + + // 初始随机分布 + for i, node := range nodes { + angle := float64(i) * 2 * math.Pi / float64(len(nodes)) + radius := spacing * 2 + positions[node.ID] = &model.CanvasPosition{ + X: radius * math.Cos(angle), + Y: radius * math.Sin(angle), + } + } + + // 简化的力导向迭代(10次) + for iter := 0; iter < 10; iter++ { + forces := make(map[string]*model.CanvasPosition) + for id := range positions { + forces[id] = &model.CanvasPosition{X: 0, Y: 0} + } + + // 计算斥力 + for id1, pos1 := range positions { + for id2, pos2 := range positions { + if id1 != id2 { + dx := pos1.X - pos2.X + dy := pos1.Y - pos2.Y + dist := math.Sqrt(dx*dx + dy*dy) + if dist < 1 { + dist = 1 + } + force := spacing * spacing / dist + forces[id1].X += dx / dist * force + forces[id1].Y += dy / dist * force + } + } + } + + // 计算引力(父子关系) + nodeMap := make(map[string]model.CanvasNode) + for _, node := range nodes { + nodeMap[node.ID] = node + } + + for _, node := range nodes { + if node.ParentID != nil { + if parentPos, ok := positions[*node.ParentID]; ok { + if childPos, ok := positions[node.ID]; ok { + dx := parentPos.X - childPos.X + dy := parentPos.Y - childPos.Y + dist := math.Sqrt(dx*dx + dy*dy) + if dist > 0 { + force := dist * 0.1 + forces[node.ID].X += dx / dist * force + forces[node.ID].Y += dy / dist * force + } + } + } + } + } + + // 应用力 + for id, force := range forces { + positions[id].X += force.X * 0.1 + positions[id].Y += force.Y * 0.1 + } + } + + return positions, nil +} + +// applyCircularLayout 环形布局 +func (e *CanvasLayoutEngine) applyCircularLayout(nodes []model.CanvasNode, spacing float64) (map[string]*model.CanvasPosition, error) { + positions := make(map[string]*model.CanvasPosition) + + if len(nodes) == 0 { + return positions, nil + } + + radius := spacing * float64(len(nodes)) / (2 * math.Pi) + if radius < spacing { + radius = spacing + } + + for i, node := range nodes { + angle := float64(i) * 2 * math.Pi / float64(len(nodes)) + positions[node.ID] = &model.CanvasPosition{ + X: radius * math.Cos(angle), + Y: radius * math.Sin(angle), + } + } + + return positions, nil +} + +// applyHierarchicalLayout 层级布局 +func (e *CanvasLayoutEngine) applyHierarchicalLayout(nodes []model.CanvasNode, direction string, spacing float64) (map[string]*model.CanvasPosition, error) { + positions := make(map[string]*model.CanvasPosition) + + // 计算每个节点的层级 + levels := make(map[string]int) + nodeMap := make(map[string]model.CanvasNode) + for _, node := range nodes { + nodeMap[node.ID] = node + } + + // 找根节点 + roots := make([]string, 0) + for _, node := range nodes { + if node.ParentID == nil { + roots = append(roots, node.ID) + levels[node.ID] = 0 + } + } + + // BFS 计算层级 + queue := make([]string, len(roots)) + copy(queue, roots) + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + currentLevel := levels[current] + node := nodeMap[current] + + for _, childID := range node.Children { + if _, visited := levels[childID]; !visited { + levels[childID] = currentLevel + 1 + queue = append(queue, childID) + } + } + } + + // 按层级分组 + levelGroups := make(map[int][]string) + maxLevel := 0 + for id, level := range levels { + levelGroups[level] = append(levelGroups[level], id) + if level > maxLevel { + maxLevel = level + } + } + + // 布局每一层 + if direction == "horizontal" { + for level := 0; level <= maxLevel; level++ { + nodeIDs := levelGroups[level] + x := float64(level) * spacing * 2 + for i, nodeID := range nodeIDs { + y := float64(i) * spacing + positions[nodeID] = &model.CanvasPosition{X: x, Y: y} + } + } + } else { + for level := 0; level <= maxLevel; level++ { + nodeIDs := levelGroups[level] + y := float64(level) * spacing * 2 + for i, nodeID := range nodeIDs { + x := float64(i) * spacing + positions[nodeID] = &model.CanvasPosition{X: x, Y: y} + } + } + } + + return positions, nil +} + +// Canvas Search Engine - 搜索引擎 + +type CanvasSearchEngine struct { + service *Service +} + +type CanvasSearchQuery struct { + Query string + Type string + Category string + Tags []string + Limit int +} + +type CanvasSearchResult struct { + Node model.CanvasNode `json:"node"` + Score float64 `json:"score"` + Highlight string `json:"highlight"` +} + +func NewCanvasSearchEngine(service *Service) *CanvasSearchEngine { + return &CanvasSearchEngine{service: service} +} + +// Search 执行搜索 +func (e *CanvasSearchEngine) Search(userID, canvasID string, query CanvasSearchQuery) ([]CanvasSearchResult, error) { + // 获取所有节点 + nodes, err := e.service.repo.CanvasNodes(userID, canvasID) + if err != nil { + return nil, fmt.Errorf("get nodes: %w", err) + } + + results := make([]CanvasSearchResult, 0) + + // 搜索和评分 + for _, node := range nodes { + score := e.calculateScore(node, query) + if score > 0 { + highlight := e.generateHighlight(node, query) + results = append(results, CanvasSearchResult{ + Node: node, + Score: score, + Highlight: highlight, + }) + } + } + + // 排序 + sort.Slice(results, func(i, j int) bool { + return results[i].Score > results[j].Score + }) + + // 限制结果数量 + if query.Limit > 0 && len(results) > query.Limit { + results = results[:query.Limit] + } + + return results, nil +} + +// calculateScore 计算搜索得分 +func (e *CanvasSearchEngine) calculateScore(node model.CanvasNode, query CanvasSearchQuery) float64 { + score := 0.0 + + // 类型匹配 + if query.Type != "" && node.Type != query.Type { + return 0 + } + + // 关键词搜索 + if query.Query != "" { + queryLower := strings.ToLower(query.Query) + contentLower := strings.ToLower(node.Content) + + // 精确匹配 + if strings.Contains(contentLower, queryLower) { + score += 10.0 + + // 标题匹配(前100字符) + if len(contentLower) > 100 && strings.Contains(contentLower[:100], queryLower) { + score += 5.0 + } + + // 完整词匹配 + words := strings.Fields(contentLower) + for _, word := range words { + if word == queryLower { + score += 3.0 + } + } + } + + // 模糊匹配(分词) + queryWords := strings.Fields(queryLower) + for _, qWord := range queryWords { + if strings.Contains(contentLower, qWord) { + score += 1.0 + } + } + } + + // 分类匹配 + if query.Category != "" { + ci := e.service.NewCanvasIntelligence() + nodeCategory := ci.categorizeNode(node) + if nodeCategory == query.Category { + score += 5.0 + } + } + + // 标签匹配 + if len(query.Tags) > 0 && node.Metadata != nil { + if nodeTags, ok := node.Metadata["tags"].([]interface{}); ok { + matchedTags := 0 + for _, qt := range query.Tags { + for _, nt := range nodeTags { + if ntStr, ok := nt.(string); ok && ntStr == qt { + matchedTags++ + } + } + } + score += float64(matchedTags) * 3.0 + } + } + + return score +} + +// generateHighlight 生成高亮摘要 +func (e *CanvasSearchEngine) generateHighlight(node model.CanvasNode, query CanvasSearchQuery) string { + if query.Query == "" { + if len(node.Content) > 100 { + return node.Content[:100] + "..." + } + return node.Content + } + + queryLower := strings.ToLower(query.Query) + contentLower := strings.ToLower(node.Content) + + // 找到匹配位置 + index := strings.Index(contentLower, queryLower) + if index == -1 { + // 没找到精确匹配,返回开头 + if len(node.Content) > 100 { + return node.Content[:100] + "..." + } + return node.Content + } + + // 提取匹配周围的文本 + start := index - 50 + if start < 0 { + start = 0 + } + + end := index + len(query.Query) + 50 + if end > len(node.Content) { + end = len(node.Content) + } + + highlight := "" + if start > 0 { + highlight += "..." + } + highlight += node.Content[start:end] + if end < len(node.Content) { + highlight += "..." + } + + return highlight +} + +// Canvas Event Handlers - 事件处理器 + +// handleMessageStart 处理消息开始事件 +func (s *Service) handleMessageStart(userID, runID string, data map[string]any) (any, error) { + // 记录消息开始 + log.Printf("[Agent] message_start run=%s", runID) + + // 更新运行状态 + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + + // 标记正在生成响应 + state.IsGenerating = true + + if err := s.saveCloudAgentRuntimeState(userID, runID, &state); err != nil { + return nil, err + } + + return map[string]any{"ok": true}, nil +} + +// handleMessageDelta 处理消息增量事件 +func (s *Service) handleMessageDelta(userID, runID string, data map[string]any) (any, error) { + delta, _ := data["delta"].(string) + messageID, _ := data["messageId"].(string) + log.Printf("[Agent] message_delta run=%s messageId=%s len=%d", runID, messageID, len(delta)) + + // 推送实时增量到前端 + if err := s.broadcastAgentEvent(userID, runID, "message_delta", map[string]any{ + "delta": delta, + "messageId": messageID, + }); err != nil { + log.Printf("[Agent] failed to broadcast message_delta: %v", err) + } + + return map[string]any{"ok": true}, nil +} + +// handleMessageEnd 处理消息结束事件(关键修复点) +func (s *Service) handleMessageEnd(userID, runID string, data map[string]any) (any, error) { + log.Printf("[Agent] message_end run=%s data=%+v", runID, data) + + // 检查消息角色,防止将用户消息误认为助手消息 + role, _ := data["role"].(string) + if role == "user" { + // 这是用户消息结束,不是助手响应,直接返回 + log.Printf("[Agent] ignoring user message_end run=%s", runID) + return map[string]any{"ok": true, "ignored": true, "reason": "user message"}, nil + } + + // 只处理助手消息结束 + if role != "assistant" { + log.Printf("[Agent] ignoring non-assistant message_end run=%s role=%s", runID, role) + return map[string]any{"ok": true, "ignored": true, "reason": fmt.Sprintf("non-assistant role: %s", role)}, nil + } + + // 更新运行状态 + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + + // 标记生成完成 + state.IsGenerating = false + + // 计数真实的助手响应 + state.PiAssistantResponses++ + + log.Printf("[Agent] assistant response completed run=%s count=%d", runID, state.PiAssistantResponses) + + if err := s.saveCloudAgentRuntimeState(userID, runID, &state); err != nil { + return nil, err + } + + return map[string]any{"ok": true, "assistantResponses": state.PiAssistantResponses}, nil +} + +// handleToolCall 处理工具调用事件 +func (s *Service) handleToolCall(userID, runID string, data map[string]any) (any, error) { + toolName, _ := data["toolName"].(string) + log.Printf("[Agent] tool_call run=%s tool=%s", runID, toolName) + + return map[string]any{"ok": true}, nil +} + +// handleError 处理错误事件 +func (s *Service) handleError(userID, runID string, data map[string]any) (any, error) { + errorMsg, _ := data["error"].(string) + log.Printf("[Agent] error run=%s error=%s", runID, errorMsg) + + // 更新运行状态为失败 + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + + state.IsGenerating = false + state.LastError = errorMsg + + if err := s.saveCloudAgentRuntimeState(userID, runID, &state); err != nil { + return nil, err + } + + return map[string]any{"ok": true}, nil +} + +// saveCloudAgentRuntimeState persists only the Pi control fields changed by an +// event handler, merging them into the latest revision before checkpointing. +func (s *Service) saveCloudAgentRuntimeState(userID, runID string, state *cloudAgentRuntime) error { + if state == nil { + return fmt.Errorf("missing Agent runtime state") + } + for attempt := 0; attempt < 4; 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 + } + fresh.IsGenerating = state.IsGenerating + fresh.LastError = state.LastError + fresh.PiAssistantResponses = state.PiAssistantResponses + return cloudAgentSave(current, &fresh) + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + return repository.ErrCreationConflict +} + +func (s *Service) broadcastAgentEvent(userID, runID, kind string, payload map[string]any) error { + for attempt := 0; attempt < 4; 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 { + state, err := cloudAgentDecode(current) + if err != nil { + return err + } + state.event(runID, kind, payload) + return cloudAgentSave(current, &state) + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + return repository.ErrCreationConflict +} + +// updatePiRunWithStep 更新运行状态(添加步骤) +func (s *Service) updatePiRunWithStep(userID, runID string, revision int64, stepID string, state *cloudAgentRuntime) error { + return s.repo.MutateCloudAgent(userID, runID, revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + state.ActiveTaskID = stepID + return cloudAgentSave(current, state) + }) +} + +// routeStandardTool 路由标准工具(非画布工具) +func (s *Service) routeStandardTool(ctx context.Context, userID, runID, toolName string, payload map[string]json.RawMessage) (any, error) { + log.Printf("[Agent] routing standard tool: %s", toolName) + + // 将参数从 json.RawMessage 转换为 map[string]any + args := make(map[string]any) + for key, rawValue := range payload { + var value any + if err := json.Unmarshal(rawValue, &value); err != nil { + return nil, fmt.Errorf("decode standard tool parameter %q: %w", key, err) + } + args[key] = value + } + + // 路由到对应的工具处理器 + switch toolName { + case "read_file", "write_file", "edit_file", "delete_file": + return s.handleFileOperation(ctx, userID, runID, toolName, args) + case "list_directory", "search_files", "glob_files": + return s.handleFileSearch(ctx, userID, runID, toolName, args) + case "bash", "shell_command": + return s.handleShellCommand(ctx, userID, runID, args) + case "web_search", "fetch_url": + return s.handleWebOperation(ctx, userID, runID, toolName, args) + default: + return map[string]any{ + "success": false, + "error": fmt.Sprintf("unsupported standard tool: %s", toolName), + }, nil + } +} + +// handleFileOperation 处理文件操作工具 +func (s *Service) handleFileOperation(ctx context.Context, userID, runID, toolName string, args map[string]any) (any, error) { + return nil, fmt.Errorf("standard tool %q is unavailable: file access is not configured", toolName) +} + +// handleFileSearch 处理文件搜索工具 +func (s *Service) handleFileSearch(ctx context.Context, userID, runID, toolName string, args map[string]any) (any, error) { + return nil, fmt.Errorf("standard tool %q is unavailable: file search is not configured", toolName) +} + +// handleShellCommand 处理 Shell 命令工具 +func (s *Service) handleShellCommand(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + return nil, errors.New("standard tool shell execution is unavailable: no approval-backed executor is configured") +} + +// handleWebOperation 处理 Web 操作工具 +func (s *Service) handleWebOperation(ctx context.Context, userID, runID, toolName string, args map[string]any) (any, error) { + return nil, fmt.Errorf("standard tool %q is unavailable: web access is not configured", toolName) +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_intelligence.go b/backend/internal/app/cloud_agent_pi_canvas_intelligence.go new file mode 100644 index 000000000..9dced6c27 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_intelligence.go @@ -0,0 +1,521 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// Canvas Intelligence Layer - 让 Agent 深度理解画布 +// 这是重构的核心:从"工具调用"升级到"画布认知" + +// CanvasIntelligence 画布智能层 +type CanvasIntelligence struct { + service *Service +} + +// NewCanvasIntelligence 创建画布智能层 +func (s *Service) NewCanvasIntelligence() *CanvasIntelligence { + return &CanvasIntelligence{service: s} +} + +// EnhancedCanvasContext 增强的画布上下文(传递给Pi) +type EnhancedCanvasContext struct { + // 基础信息 + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + + // 结构化快照 + Snapshot CanvasSnapshot `json:"snapshot"` + + // 智能洞察 + Intelligence CanvasIntelligenceInsights `json:"intelligence"` + + // 能力矩阵 + Capabilities CanvasCapabilityMatrix `json:"capabilities"` + + // 实时状态 + RealtimeState CanvasRealtimeState `json:"realtimeState"` +} + +// CanvasSnapshot 画布快照(结构化) +type CanvasSnapshot struct { + Timestamp int64 `json:"timestamp"` + TotalNodes int `json:"totalNodes"` + FocusNodes []EnhancedNodeView `json:"focusNodes"` + ContextNodes []EnhancedNodeView `json:"contextNodes"` // 焦点节点周边相关节点 + Relationships []RelationshipView `json:"relationships"` + Layout LayoutAnalysis `json:"layout"` + Hierarchy HierarchyView `json:"hierarchy"` +} + +// EnhancedNodeView 增强的节点视图(更智能的表示) +type EnhancedNodeView struct { + // 标识 + ID string `json:"id"` + Type string `json:"type"` + + // 内容摘要(智能压缩) + ContentPreview string `json:"contentPreview"` // 前200字符 + ContentHash string `json:"contentHash"` + ContentLength int `json:"contentLength"` + HasFullContent bool `json:"hasFullContent"` // 是否在焦点中(有完整内容) + + // 完整内容(仅焦点节点) + FullContent string `json:"fullContent,omitempty"` + + // 空间信息 + Position *Position `json:"position,omitempty"` + Size *Size `json:"size,omitempty"` + ZIndex int `json:"zIndex"` + + // 关系 + ParentID string `json:"parentId,omitempty"` + Children []string `json:"children,omitempty"` + Siblings []string `json:"siblings,omitempty"` // 同级节点 + + // 语义标签 + Tags []string `json:"tags,omitempty"` + Category string `json:"category,omitempty"` // 自动分类 + Sentiment string `json:"sentiment,omitempty"` // 情感:positive/neutral/negative/todo + Keywords []string `json:"keywords,omitempty"` // 关键词提取 + + // 状态 + IsFocus bool `json:"isFocus"` + IsModified bool `json:"isModified"` // 最近是否被修改 + Status string `json:"status"` // draft/review/approved/archived + + // 元数据 + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + UpdatedBy string `json:"updatedBy,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// Position 位置 +type Position struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Size 尺寸 +type Size struct { + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +// RelationshipView 关系视图 +type RelationshipView struct { + From string `json:"from"` + To string `json:"to"` + Type string `json:"type"` // parent/reference/dependency/flow/semantic + Strength float64 `json:"strength"` // 0-1,关系强度 + Bidirection bool `json:"bidirection"` // 是否双向 + Label string `json:"label,omitempty"` +} + +// LayoutAnalysis 布局分析 +type LayoutAnalysis struct { + Algorithm string `json:"algorithm"` // detected: tree/grid/force/freeform + Direction string `json:"direction"` // horizontal/vertical/radial + Density float64 `json:"density"` // 0-1,节点密度 + Clusters []ClusterInfo `json:"clusters"` // 聚类分析 + Bounds Bounds `json:"bounds"` // 画布边界 + ViewportFit string `json:"viewportFit"` // 视口适配建议 +} + +// ClusterInfo 聚类信息 +type ClusterInfo struct { + ID string `json:"id"` + NodeIDs []string `json:"nodeIds"` + Center Position `json:"center"` + Label string `json:"label"` // 聚类主题 + Cohesion float64 `json:"cohesion"` // 0-1,内聚度 +} + +// Bounds 边界 +type Bounds struct { + MinX float64 `json:"minX"` + MinY float64 `json:"minY"` + MaxX float64 `json:"maxX"` + MaxY float64 `json:"maxY"` +} + +// HierarchyView 层级视图 +type HierarchyView struct { + MaxDepth int `json:"maxDepth"` + RootNodes []string `json:"rootNodes"` + Tree map[string]TreeNode `json:"tree"` // nodeID -> TreeNode +} + +// TreeNode 树节点 +type TreeNode struct { + ID string `json:"id"` + Depth int `json:"depth"` + Children []string `json:"children"` + Path []string `json:"path"` // 从根到当前节点的路径 +} + +// CanvasIntelligenceInsights 画布智能洞察 +type CanvasIntelligenceInsights struct { + // 自动总结 + Summary CanvasSummary `json:"summary"` + + // 建议 + Suggestions []ActionSuggestion `json:"suggestions"` + + // 模式识别 + Patterns []PatternRecognition `json:"patterns"` + + // 问题诊断 + Issues []CanvasIssue `json:"issues"` + + // 趋势分析 + Trends TrendAnalysis `json:"trends"` +} + +// CanvasSummary 画布总结 +type CanvasSummary struct { + OneSentence string `json:"oneSentence"` // 一句话总结 + MainTopics []string `json:"mainTopics"` // 主要主题 + Progress string `json:"progress"` // 进度评估:early/mid/mature + Completeness float64 `json:"completeness"` // 0-1,完整性评分 +} + +// ActionSuggestion 行动建议 +type ActionSuggestion struct { + Priority int `json:"priority"` // 1-5 + Action string `json:"action"` // 建议的操作 + Reason string `json:"reason"` // 原因 + Impact string `json:"impact"` // 预期影响 +} + +// PatternRecognition 模式识别 +type PatternRecognition struct { + Pattern string `json:"pattern"` // 识别的模式名称 + Confidence float64 `json:"confidence"` // 0-1,置信度 + Evidence []string `json:"evidence"` // 证据节点 + Description string `json:"description"` // 描述 +} + +// CanvasIssue 画布问题 +type CanvasIssue struct { + Severity string `json:"severity"` // low/medium/high/critical + Type string `json:"type"` // orphan/duplicate/inconsistent/incomplete + Description string `json:"description"` // 问题描述 + AffectedNodes []string `json:"affectedNodes"` // 受影响的节点 + FixSuggestion string `json:"fixSuggestion"` // 修复建议 +} + +// TrendAnalysis 趋势分析 +type TrendAnalysis struct { + RecentActivity ActivityLevel `json:"recentActivity"` + GrowthRate float64 `json:"growthRate"` // 节点增长率 + HotAreas []string `json:"hotAreas"` // 热门区域 + CollaborationMap map[string]int `json:"collaborationMap"` // userID -> 贡献数 +} + +// ActivityLevel 活动水平 +type ActivityLevel struct { + Level string `json:"level"` // low/medium/high + LastUpdated int64 `json:"lastUpdated"` // 最后更新时间 + UpdateCount int `json:"updateCount"` // 更新次数(最近1小时) +} + +// CanvasCapabilityMatrix 画布能力矩阵 +type CanvasCapabilityMatrix struct { + // 节点类型支持 + SupportedNodeTypes []NodeTypeCapability `json:"supportedNodeTypes"` + + // 操作权限 + Permissions CanvasPermissions `json:"permissions"` + + // 限制 + Limits CanvasLimits `json:"limits"` + + // 特性开关 + Features map[string]bool `json:"features"` + + // 扩展点 + Extensions []ExtensionPoint `json:"extensions"` +} + +// NodeTypeCapability 节点类型能力 +type NodeTypeCapability struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Icon string `json:"icon,omitempty"` + Creatable bool `json:"creatable"` + Operations []string `json:"operations"` // read/update/delete/move/duplicate + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CanvasPermissions 画布权限 +type CanvasPermissions struct { + CanRead bool `json:"canRead"` + CanCreate bool `json:"canCreate"` + CanUpdate bool `json:"canUpdate"` + CanDelete bool `json:"canDelete"` + CanMove bool `json:"canMove"` + CanShare bool `json:"canShare"` + RequireApproval []string `json:"requireApproval"` // 需要审批的操作 +} + +// CanvasLimits 画布限制 +type CanvasLimits struct { + MaxNodes int `json:"maxNodes"` + MaxDepth int `json:"maxDepth"` + MaxContentSize int `json:"maxContentSize"` // bytes + MaxBatchSize int `json:"maxBatchSize"` // 批量操作限制 +} + +// ExtensionPoint 扩展点 +type ExtensionPoint struct { + Name string `json:"name"` + Type string `json:"type"` // hook/plugin/custom + Enabled bool `json:"enabled"` + Description string `json:"description"` +} + +// CanvasRealtimeState 画布实时状态 +type CanvasRealtimeState struct { + OnlineUsers []string `json:"onlineUsers"` + ActiveCursors map[string]Position `json:"activeCursors"` // userID -> Position + LockedNodes []string `json:"lockedNodes"` + PendingOperations int `json:"pendingOperations"` + SyncStatus string `json:"syncStatus"` // synced/syncing/conflict +} + +// BuildEnhancedCanvasContext constructs the Agent context from CanvasProject, +// the application's persisted canvas document. The extension Canvas/CanvasNode +// models are not part of the production schema. +func (ci *CanvasIntelligence) BuildEnhancedCanvasContext(ctx context.Context, userID, canvasID string, focusNodeIDs []string) (*EnhancedCanvasContext, error) { + canvas, err := ci.service.repo.CanvasProjectForUser(userID, canvasID) + if err != nil { + return nil, fmt.Errorf("get canvas: %w", err) + } + document, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, fmt.Errorf("parse canvas document: %w", err) + } + nodes := canvasIntelligenceNodes(canvas, document) + + // 2. 构建结构化快照 + snapshot := ci.buildSnapshot(nodes, focusNodeIDs, canvasIntelligenceRelationships(document, nodes)) + + // 3. 生成智能洞察 + intelligence := ci.generateIntelligence(nodes, snapshot) + + // 4. 构建能力矩阵 + capabilities := ci.buildCapabilityMatrix(userID, canvasID) + + // 5. 获取实时状态 + realtimeState := ci.getRealtimeState(ctx, document) + + return &EnhancedCanvasContext{ + ID: canvasID, + Name: canvas.Title, + Description: stringValue(document["description"]), + Snapshot: snapshot, + Intelligence: intelligence, + Capabilities: capabilities, + RealtimeState: realtimeState, + }, nil +} + +// buildSnapshot 构建画布快照 +func (ci *CanvasIntelligence) buildSnapshot(nodes []model.CanvasNode, focusNodeIDs []string, explicitRelationships ...[]RelationshipView) CanvasSnapshot { + focusMap := make(map[string]bool) + for _, id := range focusNodeIDs { + focusMap[id] = true + } + + // 构建节点视图 + focusNodes := make([]EnhancedNodeView, 0) + contextNodes := make([]EnhancedNodeView, 0) + + for _, node := range nodes { + view := ci.buildEnhancedNodeView(node, focusMap[node.ID]) + if focusMap[node.ID] { + focusNodes = append(focusNodes, view) + } else { + // 判断是否是上下文节点(焦点节点的相邻节点) + if ci.isContextNode(node, focusNodeIDs, nodes) { + contextNodes = append(contextNodes, view) + } + } + } + + // 构建关系 + relationships := ci.buildRelationships(nodes) + if len(explicitRelationships) > 0 { + relationships = append(explicitRelationships[0], relationships...) + } + + // 分析布局 + layout := ci.analyzeLayout(nodes) + + // 构建层级 + hierarchy := ci.buildHierarchy(nodes) + + return CanvasSnapshot{ + Timestamp: time.Now().Unix(), + TotalNodes: len(nodes), + FocusNodes: focusNodes, + ContextNodes: contextNodes, + Relationships: relationships, + Layout: layout, + Hierarchy: hierarchy, + } +} + +func canvasIntelligenceNodes(canvas *model.CanvasProject, document map[string]any) []model.CanvasNode { + rawNodes := creationMaps(document["nodes"]) + nodes := make([]model.CanvasNode, 0, len(rawNodes)) + children := make(map[string][]string) + + for _, raw := range rawNodes { + id := stringValue(raw["id"]) + if id == "" { + continue + } + metadata := canvasIntelligenceMetadata(raw) + content := firstNonEmpty( + stringValue(raw["content"]), + stringValue(metadata["content"]), + stringValue(raw["title"]), + ) + var parentID *string + if value := stringValue(raw["parentId"]); value != "" { + parentID = &value + children[value] = append(children[value], id) + } + nodes = append(nodes, model.CanvasNode{ + ID: id, CanvasID: canvas.ID, Type: stringValue(raw["type"]), Content: content, + Position: canvasIntelligencePosition(raw), Size: canvasIntelligenceSize(raw), ParentID: parentID, Metadata: metadata, + CreatedAt: canvas.CreatedAt, UpdatedAt: canvas.UpdatedAt, + }) + } + for index := range nodes { + nodes[index].Children = children[nodes[index].ID] + } + return nodes +} + +func canvasIntelligenceMetadata(raw map[string]any) map[string]any { + metadata := map[string]any{} + if value, ok := raw["metadata"].(map[string]any); ok { + for key, item := range value { + metadata[key] = item + } + } + if value, ok := raw["data"].(map[string]any); ok { + for key, item := range value { + if _, exists := metadata[key]; !exists { + metadata[key] = item + } + } + } + return metadata +} + +func canvasIntelligencePosition(raw map[string]any) *model.CanvasPosition { + position, _ := raw["position"].(map[string]any) + x, y := numberValue(raw["x"], 0), numberValue(raw["y"], 0) + if position != nil { + x, y = numberValue(position["x"], x), numberValue(position["y"], y) + } + if position == nil && raw["x"] == nil && raw["y"] == nil { + return nil + } + return &model.CanvasPosition{X: x, Y: y} +} + +func canvasIntelligenceSize(raw map[string]any) *model.CanvasSize { + size, _ := raw["size"].(map[string]any) + width, height := numberValue(raw["width"], 0), numberValue(raw["height"], 0) + if size != nil { + width, height = numberValue(size["width"], width), numberValue(size["height"], height) + } + if size == nil && raw["width"] == nil && raw["height"] == nil { + return nil + } + return &model.CanvasSize{Width: width, Height: height} +} + +func canvasIntelligenceRelationships(document map[string]any, nodes []model.CanvasNode) []RelationshipView { + known := make(map[string]bool, len(nodes)) + for _, node := range nodes { + known[node.ID] = true + } + relationships := make([]RelationshipView, 0) + for _, edge := range creationMaps(document["connections"]) { + from, to := stringValue(edge["fromNodeId"]), stringValue(edge["toNodeId"]) + if from == "" || to == "" || !known[from] || !known[to] { + continue + } + typeName := strings.TrimSpace(stringValue(edge["type"])) + if typeName == "" { + typeName = "flow" + } + relationships = append(relationships, RelationshipView{ + From: from, To: to, Type: typeName, Strength: 1, Label: stringValue(edge["label"]), + }) + } + return relationships +} + +// buildEnhancedNodeView 构建增强节点视图 +func (ci *CanvasIntelligence) buildEnhancedNodeView(node model.CanvasNode, isFocus bool) EnhancedNodeView { + // 智能内容摘要 + preview := node.Content + if len(preview) > 200 { + preview = preview[:200] + "..." + } + + // 提取关键词(简化实现) + keywords := ci.extractKeywords(node.Content) + + // 自动分类 + category := ci.categorizeNode(node) + + // 情感分析(简化) + sentiment := ci.analyzeSentiment(node.Content) + + view := EnhancedNodeView{ + ID: node.ID, + Type: node.Type, + ContentPreview: preview, + ContentHash: hashContentSHA256(node.Content), + ContentLength: len(node.Content), + HasFullContent: isFocus, + Position: ci.convertPosition(node.Position), + Size: ci.convertSize(node.Size), + ZIndex: ci.getZIndex(node), + ParentID: ci.getParentID(node.ParentID), + Children: node.Children, + Tags: ci.extractTags(node), + Category: category, + Sentiment: sentiment, + Keywords: keywords, + IsFocus: isFocus, + IsModified: ci.isRecentlyModified(node), + Status: ci.getNodeStatus(node), + CreatedAt: node.CreatedAt.Unix(), + UpdatedAt: node.UpdatedAt.Unix(), + Metadata: node.Metadata, + } + + if isFocus { + view.FullContent = node.Content + } + + return view +} + +// Continue in next chunk... diff --git a/backend/internal/app/cloud_agent_pi_canvas_intelligence_analysis.go b/backend/internal/app/cloud_agent_pi_canvas_intelligence_analysis.go new file mode 100644 index 000000000..b39efba13 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_intelligence_analysis.go @@ -0,0 +1,747 @@ +package app + +import ( + "context" + "fmt" + "log" + "sort" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// generateIntelligence 生成智能洞察 +func (ci *CanvasIntelligence) generateIntelligence(nodes []model.CanvasNode, snapshot CanvasSnapshot) CanvasIntelligenceInsights { + return CanvasIntelligenceInsights{ + Summary: ci.generateSummary(nodes, snapshot), + Suggestions: ci.generateSuggestions(nodes, snapshot), + Patterns: ci.recognizePatterns(nodes, snapshot), + Issues: ci.diagnoseIssues(nodes, snapshot), + Trends: ci.analyzeTrends(nodes), + } +} + +// generateSummary 生成画布总结 +func (ci *CanvasIntelligence) generateSummary(nodes []model.CanvasNode, snapshot CanvasSnapshot) CanvasSummary { + // 提取主要主题 + mainTopics := ci.extractMainTopics(nodes) + + // 评估进度 + progress := ci.assessProgress(nodes) + + // 计算完整性 + completeness := ci.calculateCompleteness(nodes, snapshot) + + // 生成一句话总结 + oneSentence := ci.generateOneSentenceSummary(nodes, snapshot, mainTopics) + + return CanvasSummary{ + OneSentence: oneSentence, + MainTopics: mainTopics, + Progress: progress, + Completeness: completeness, + } +} + +// extractMainTopics 提取主要主题 +func (ci *CanvasIntelligence) extractMainTopics(nodes []model.CanvasNode) []string { + // 收集所有关键词 + allKeywords := make(map[string]int) + for _, node := range nodes { + keywords := ci.extractKeywords(node.Content) + for _, kw := range keywords { + allKeywords[kw]++ + } + } + + // 排序取前3个 + type kv struct { + Key string + Value int + } + var sorted []kv + for k, v := range allKeywords { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Value > sorted[j].Value + }) + + topics := make([]string, 0, 3) + for i := 0; i < len(sorted) && i < 3; i++ { + topics = append(topics, sorted[i].Key) + } + + return topics +} + +// assessProgress 评估进度 +func (ci *CanvasIntelligence) assessProgress(nodes []model.CanvasNode) string { + if len(nodes) < 5 { + return "early" + } + + // 统计完成状态 + completed := 0 + total := 0 + + for _, node := range nodes { + category := ci.categorizeNode(node) + if category == "task" { + total++ + sentiment := ci.analyzeSentiment(node.Content) + if sentiment == "positive" { + completed++ + } + } + } + + if total == 0 { + // 没有任务节点,根据节点数量判断 + if len(nodes) < 10 { + return "early" + } else if len(nodes) < 30 { + return "mid" + } else { + return "mature" + } + } + + completionRate := float64(completed) / float64(total) + if completionRate < 0.3 { + return "early" + } else if completionRate < 0.7 { + return "mid" + } else { + return "mature" + } +} + +// calculateCompleteness 计算完整性 +func (ci *CanvasIntelligence) calculateCompleteness(nodes []model.CanvasNode, snapshot CanvasSnapshot) float64 { + score := 0.0 + + // 1. 节点数量(20%) + if len(nodes) > 0 { + score += 0.2 + } + + // 2. 结构完整性(20%) + if len(snapshot.Hierarchy.RootNodes) > 0 { + score += 0.2 + } + + // 3. 内容质量(30%) + contentScore := 0.0 + for _, node := range nodes { + if len(node.Content) > 50 { + contentScore += 1.0 + } + } + if len(nodes) > 0 { + contentScore /= float64(len(nodes)) + score += contentScore * 0.3 + } + + // 4. 关系完整性(30%) + if len(snapshot.Relationships) > 0 { + relationshipRatio := float64(len(snapshot.Relationships)) / float64(len(nodes)) + if relationshipRatio > 1 { + relationshipRatio = 1 + } + score += relationshipRatio * 0.3 + } + + return score +} + +// generateOneSentenceSummary 生成一句话总结 +func (ci *CanvasIntelligence) generateOneSentenceSummary(nodes []model.CanvasNode, snapshot CanvasSnapshot, mainTopics []string) string { + nodeCount := len(nodes) + progress := ci.assessProgress(nodes) + + topicsStr := "various topics" + if len(mainTopics) > 0 { + topicsStr = strings.Join(mainTopics, ", ") + } + + progressMap := map[string]string{ + "early": "刚开始", + "mid": "进行中", + "mature": "接近完成", + } + + return fmt.Sprintf("包含 %d 个节点的画布,主题:%s,状态:%s", nodeCount, topicsStr, progressMap[progress]) +} + +// generateSuggestions 生成行动建议 +func (ci *CanvasIntelligence) generateSuggestions(nodes []model.CanvasNode, snapshot CanvasSnapshot) []ActionSuggestion { + suggestions := make([]ActionSuggestion, 0) + + // 1. 孤立节点建议 + orphans := ci.findOrphanNodes(nodes, snapshot) + if len(orphans) > 0 { + suggestions = append(suggestions, ActionSuggestion{ + Priority: 3, + Action: "连接孤立节点", + Reason: fmt.Sprintf("发现 %d 个孤立节点,建立关联可以增强结构", len(orphans)), + Impact: "提升画布结构完整性", + }) + } + + // 2. 布局优化建议 + if snapshot.Layout.Density > 0.8 { + suggestions = append(suggestions, ActionSuggestion{ + Priority: 2, + Action: "优化布局密度", + Reason: "当前布局密度过高,可能影响可读性", + Impact: "改善视觉体验和导航效率", + }) + } + + // 3. 内容完善建议 + emptyNodes := ci.findEmptyOrShortNodes(nodes) + if len(emptyNodes) > 0 { + suggestions = append(suggestions, ActionSuggestion{ + Priority: 4, + Action: "完善节点内容", + Reason: fmt.Sprintf("发现 %d 个内容较少的节点", len(emptyNodes)), + Impact: "提高画布信息密度", + }) + } + + // 4. 分类整理建议 + if len(nodes) > 20 && len(snapshot.Layout.Clusters) < 3 { + suggestions = append(suggestions, ActionSuggestion{ + Priority: 3, + Action: "添加分类节点", + Reason: "节点数量较多但缺少明确分类", + Impact: "改善信息架构和可维护性", + }) + } + + // 5. 任务跟进建议 + incompleteTasks := ci.findIncompleteTasks(nodes) + if len(incompleteTasks) > 0 { + suggestions = append(suggestions, ActionSuggestion{ + Priority: 5, + Action: "跟进待办任务", + Reason: fmt.Sprintf("有 %d 个待办任务需要处理", len(incompleteTasks)), + Impact: "推进工作进度", + }) + } + + // 按优先级排序 + sort.Slice(suggestions, func(i, j int) bool { + return suggestions[i].Priority > suggestions[j].Priority + }) + + return suggestions +} + +// recognizePatterns 识别模式 +func (ci *CanvasIntelligence) recognizePatterns(nodes []model.CanvasNode, snapshot CanvasSnapshot) []PatternRecognition { + patterns := make([]PatternRecognition, 0) + + // 1. 思维导图模式 + if ci.isMindMapPattern(snapshot) { + patterns = append(patterns, PatternRecognition{ + Pattern: "mindmap", + Confidence: 0.85, + Evidence: snapshot.Hierarchy.RootNodes, + Description: "检测到思维导图结构:以中心主题向外扩展", + }) + } + + // 2. 看板模式 + if ci.isKanbanPattern(snapshot) { + patterns = append(patterns, PatternRecognition{ + Pattern: "kanban", + Confidence: 0.75, + Evidence: []string{}, + Description: "检测到看板模式:按阶段组织的任务流", + }) + } + + // 3. 时间线模式 + if ci.isTimelinePattern(nodes, snapshot) { + patterns = append(patterns, PatternRecognition{ + Pattern: "timeline", + Confidence: 0.7, + Evidence: []string{}, + Description: "检测到时间线模式:按时间顺序排列的事件", + }) + } + + // 4. 层级结构模式 + if snapshot.Hierarchy.MaxDepth >= 3 { + patterns = append(patterns, PatternRecognition{ + Pattern: "hierarchy", + Confidence: 0.8, + Evidence: snapshot.Hierarchy.RootNodes, + Description: fmt.Sprintf("检测到层级结构:最大深度 %d 层", snapshot.Hierarchy.MaxDepth), + }) + } + + // 5. 协作模式 + if ci.isCollaborativePattern(nodes) { + patterns = append(patterns, PatternRecognition{ + Pattern: "collaborative", + Confidence: 0.65, + Evidence: []string{}, + Description: "检测到协作模式:多人参与编辑", + }) + } + + return patterns +} + +// diagnoseIssues 诊断问题 +func (ci *CanvasIntelligence) diagnoseIssues(nodes []model.CanvasNode, snapshot CanvasSnapshot) []CanvasIssue { + issues := make([]CanvasIssue, 0) + + // 1. 孤立节点 + orphans := ci.findOrphanNodes(nodes, snapshot) + if len(orphans) > 0 { + issues = append(issues, CanvasIssue{ + Severity: "medium", + Type: "orphan", + Description: fmt.Sprintf("发现 %d 个孤立节点,没有与其他节点建立关系", len(orphans)), + AffectedNodes: orphans, + FixSuggestion: "使用 canvas_relationship_create 工具建立关联", + }) + } + + // 2. 重复内容 + duplicates := ci.findDuplicateNodes(nodes) + if len(duplicates) > 0 { + issues = append(issues, CanvasIssue{ + Severity: "low", + Type: "duplicate", + Description: fmt.Sprintf("发现 %d 组可能重复的节点", len(duplicates)), + AffectedNodes: ci.flattenDuplicates(duplicates), + FixSuggestion: "合并或删除重复节点", + }) + } + + // 3. 空节点 + emptyNodes := ci.findEmptyOrShortNodes(nodes) + if len(emptyNodes) > 3 { + issues = append(issues, CanvasIssue{ + Severity: "low", + Type: "incomplete", + Description: fmt.Sprintf("发现 %d 个内容较少的节点", len(emptyNodes)), + AffectedNodes: emptyNodes, + FixSuggestion: "补充节点内容或删除无用节点", + }) + } + + // 4. 布局问题 + if snapshot.Layout.Density > 0.9 { + issues = append(issues, CanvasIssue{ + Severity: "medium", + Type: "layout", + Description: "节点密度过高,可能影响可读性", + AffectedNodes: []string{}, + FixSuggestion: "使用 canvas_layout 工具重新布局", + }) + } + + // 5. 深度过深 + if snapshot.Hierarchy.MaxDepth > 5 { + issues = append(issues, CanvasIssue{ + Severity: "low", + Type: "hierarchy", + Description: fmt.Sprintf("层级深度 %d 过深,可能难以导航", snapshot.Hierarchy.MaxDepth), + AffectedNodes: []string{}, + FixSuggestion: "考虑扁平化结构或添加中间层级", + }) + } + + return issues +} + +// analyzeTrends 分析趋势 +func (ci *CanvasIntelligence) analyzeTrends(nodes []model.CanvasNode) TrendAnalysis { + // 计算最近活动 + recentActivity := ci.calculateRecentActivity(nodes) + + // 计算增长率(简化实现:基于节点创建时间) + growthRate := ci.calculateGrowthRate(nodes) + + // 识别热门区域 + hotAreas := ci.identifyHotAreas(nodes) + + // 协作地图 + collaborationMap := ci.buildCollaborationMap(nodes) + + return TrendAnalysis{ + RecentActivity: recentActivity, + GrowthRate: growthRate, + HotAreas: hotAreas, + CollaborationMap: collaborationMap, + } +} + +// 辅助方法 + +func (ci *CanvasIntelligence) findOrphanNodes(nodes []model.CanvasNode, snapshot CanvasSnapshot) []string { + connected := make(map[string]bool) + + // 标记有关系的节点 + for _, rel := range snapshot.Relationships { + connected[rel.From] = true + connected[rel.To] = true + } + + // 找出孤立节点 + orphans := make([]string, 0) + for _, node := range nodes { + if !connected[node.ID] && node.ParentID == nil && len(node.Children) == 0 { + orphans = append(orphans, node.ID) + } + } + + return orphans +} + +func (ci *CanvasIntelligence) findDuplicateNodes(nodes []model.CanvasNode) [][]string { + // 简化实现:基于内容哈希 + contentMap := make(map[string][]string) + + for _, node := range nodes { + if len(node.Content) < 10 { + continue + } + hash := hashContentSHA256(node.Content) + contentMap[hash] = append(contentMap[hash], node.ID) + } + + duplicates := make([][]string, 0) + for _, ids := range contentMap { + if len(ids) > 1 { + duplicates = append(duplicates, ids) + } + } + + return duplicates +} + +func (ci *CanvasIntelligence) flattenDuplicates(duplicates [][]string) []string { + result := make([]string, 0) + for _, group := range duplicates { + result = append(result, group...) + } + return result +} + +func (ci *CanvasIntelligence) findEmptyOrShortNodes(nodes []model.CanvasNode) []string { + result := make([]string, 0) + for _, node := range nodes { + if len(strings.TrimSpace(node.Content)) < 10 { + result = append(result, node.ID) + } + } + return result +} + +func (ci *CanvasIntelligence) findIncompleteTasks(nodes []model.CanvasNode) []string { + result := make([]string, 0) + for _, node := range nodes { + category := ci.categorizeNode(node) + sentiment := ci.analyzeSentiment(node.Content) + if category == "task" && sentiment == "todo" { + result = append(result, node.ID) + } + } + return result +} + +func (ci *CanvasIntelligence) isMindMapPattern(snapshot CanvasSnapshot) bool { + // 思维导图特征:单一根节点,多层级 + return len(snapshot.Hierarchy.RootNodes) == 1 && snapshot.Hierarchy.MaxDepth >= 2 +} + +func (ci *CanvasIntelligence) isKanbanPattern(snapshot CanvasSnapshot) bool { + // 看板特征:多个根节点(列),每个根节点下有多个子节点(卡片) + if len(snapshot.Hierarchy.RootNodes) < 2 { + return false + } + + // 检查每个根节点是否有子节点 + for _, rootID := range snapshot.Hierarchy.RootNodes { + if treeNode, ok := snapshot.Hierarchy.Tree[rootID]; ok { + if len(treeNode.Children) == 0 { + return false + } + } + } + + return true +} + +func (ci *CanvasIntelligence) isTimelinePattern(nodes []model.CanvasNode, snapshot CanvasSnapshot) bool { + // 时间线特征:节点按时间顺序排列(X轴) + if len(nodes) < 3 { + return false + } + + // 检查节点是否按X坐标排序 + positions := make([]float64, 0) + timestamps := make([]int64, 0) + + for _, node := range nodes { + if node.Position != nil { + positions = append(positions, node.Position.X) + timestamps = append(timestamps, node.CreatedAt.Unix()) + } + } + + if len(positions) < 3 { + return false + } + + // 检查位置和时间是否相关 + posIncreasing := true + timeIncreasing := true + + for i := 1; i < len(positions); i++ { + if positions[i] < positions[i-1] { + posIncreasing = false + } + if timestamps[i] < timestamps[i-1] { + timeIncreasing = false + } + } + + return posIncreasing && timeIncreasing +} + +func (ci *CanvasIntelligence) isCollaborativePattern(nodes []model.CanvasNode) bool { + // 协作特征:多个不同的更新者 + updaters := make(map[string]bool) + + for _, node := range nodes { + if node.Metadata != nil { + if updatedBy, ok := node.Metadata["updatedBy"].(string); ok && updatedBy != "" { + updaters[updatedBy] = true + } + } + } + + return len(updaters) >= 2 +} + +func (ci *CanvasIntelligence) calculateRecentActivity(nodes []model.CanvasNode) ActivityLevel { + now := time.Now() + recentUpdates := 0 + + for _, node := range nodes { + if now.Sub(node.UpdatedAt) < 1*time.Hour { + recentUpdates++ + } + } + + level := "low" + if recentUpdates > 10 { + level = "high" + } else if recentUpdates > 3 { + level = "medium" + } + + lastUpdated := int64(0) + for _, node := range nodes { + if node.UpdatedAt.Unix() > lastUpdated { + lastUpdated = node.UpdatedAt.Unix() + } + } + + return ActivityLevel{ + Level: level, + LastUpdated: lastUpdated, + UpdateCount: recentUpdates, + } +} + +func (ci *CanvasIntelligence) calculateGrowthRate(nodes []model.CanvasNode) float64 { + if len(nodes) == 0 { + return 0 + } + + // 简化实现:计算最近1天的增长 + now := time.Now() + recentNodes := 0 + + for _, node := range nodes { + if now.Sub(node.CreatedAt) < 24*time.Hour { + recentNodes++ + } + } + + return float64(recentNodes) / float64(len(nodes)) +} + +func (ci *CanvasIntelligence) identifyHotAreas(nodes []model.CanvasNode) []string { + // 简化实现:找出最近更新最频繁的区域(聚类) + recentNodes := make([]model.CanvasNode, 0) + now := time.Now() + + for _, node := range nodes { + if node.Position != nil && now.Sub(node.UpdatedAt) < 1*time.Hour { + recentNodes = append(recentNodes, node) + } + } + + if len(recentNodes) == 0 { + return []string{} + } + for index := range recentNodes { + if recentNodes[index].Position == nil { + recentNodes[index].Position = &model.CanvasPosition{} + } + } + + // 使用聚类找出热门区域 + clusterCount := 3 + if len(recentNodes) < clusterCount { + clusterCount = len(recentNodes) + } + clusters := ci.simpleKMeans(recentNodes, clusterCount) + hotAreas := make([]string, 0) + for _, cluster := range clusters { + if len(cluster.NodeIDs) > 0 { + hotAreas = append(hotAreas, cluster.Label) + } + } + + return hotAreas +} + +func (ci *CanvasIntelligence) buildCollaborationMap(nodes []model.CanvasNode) map[string]int { + collaborationMap := make(map[string]int) + + for _, node := range nodes { + if node.Metadata != nil { + if updatedBy, ok := node.Metadata["updatedBy"].(string); ok && updatedBy != "" { + collaborationMap[updatedBy]++ + } + } + } + + return collaborationMap +} + +// buildCapabilityMatrix 构建能力矩阵 +func (ci *CanvasIntelligence) buildCapabilityMatrix(userID, canvasID string) CanvasCapabilityMatrix { + return CanvasCapabilityMatrix{ + SupportedNodeTypes: ci.getSupportedNodeTypes(), + Permissions: ci.getCanvasPermissions(userID, canvasID), + Limits: ci.getCanvasLimits(), + Features: ci.getFeatures(), + Extensions: ci.getExtensions(), + } +} + +func (ci *CanvasIntelligence) getSupportedNodeTypes() []NodeTypeCapability { + return []NodeTypeCapability{ + {Type: "text", DisplayName: "文本", Description: "纯文本内容", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + {Type: "markdown", DisplayName: "Markdown", Description: "Markdown格式文本", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + {Type: "code", DisplayName: "代码", Description: "代码片段", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + {Type: "image", DisplayName: "图片", Description: "图片节点", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + {Type: "link", DisplayName: "链接", Description: "外部链接", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + {Type: "diagram", DisplayName: "图表", Description: "图表节点", Creatable: true, Operations: []string{"read", "update", "delete", "move"}}, + {Type: "mindmap", DisplayName: "思维导图", Description: "思维导图节点", Creatable: true, Operations: []string{"read", "update", "delete", "move"}}, + {Type: "task", DisplayName: "任务", Description: "待办任务", Creatable: true, Operations: []string{"read", "update", "delete", "move", "duplicate"}}, + } +} + +func (ci *CanvasIntelligence) getCanvasPermissions(userID, canvasID string) CanvasPermissions { + // CanvasProjectForUser uses the persisted canvas store and enforces the same + // owner scope as every other Agent canvas operation. + if _, err := ci.service.repo.CanvasProjectForUser(userID, canvasID); err != nil { + log.Printf("[Agent] failed to get canvas permissions for %s: %v", canvasID, err) + return CanvasPermissions{ + CanRead: true, + CanCreate: false, + CanUpdate: false, + CanDelete: false, + CanMove: false, + CanShare: false, + RequireApproval: []string{"canvas_node_create", "canvas_node_update", "canvas_node_delete", "canvas_bulk_operation"}, + } + } + return CanvasPermissions{CanRead: true, CanCreate: true, CanUpdate: true, CanDelete: true, CanMove: true, CanShare: true, RequireApproval: []string{}} +} + +func (ci *CanvasIntelligence) getCanvasLimits() CanvasLimits { + return CanvasLimits{ + MaxNodes: 10000, + MaxDepth: 10, + MaxContentSize: 1024 * 1024, // 1MB + MaxBatchSize: 50, + } +} + +func (ci *CanvasIntelligence) getFeatures() map[string]bool { + return map[string]bool{ + "real_time_collaboration": true, + "version_history": true, + "ai_suggestions": true, + "auto_layout": true, + "search": true, + "export": true, + "templates": true, + } +} + +func (ci *CanvasIntelligence) getExtensions() []ExtensionPoint { + return []ExtensionPoint{ + {Name: "custom_node_types", Type: "plugin", Enabled: true, Description: "支持自定义节点类型"}, + {Name: "webhooks", Type: "hook", Enabled: true, Description: "画布事件webhook"}, + {Name: "integrations", Type: "plugin", Enabled: true, Description: "第三方集成"}, + } +} + +// getRealtimeState 获取实时状态 +func (ci *CanvasIntelligence) getRealtimeState(ctx context.Context, document map[string]any) CanvasRealtimeState { + // Realtime presence is optional. Keep the Agent request scoped to the saved + // document instead of querying the unpersisted Canvas extension model. + state := CanvasRealtimeState{ + OnlineUsers: []string{}, + ActiveCursors: make(map[string]Position), + LockedNodes: []string{}, + PendingOperations: 0, + SyncStatus: "synced", + } + + metadata, ok := document["metadata"].(map[string]any) + if !ok { + return state + } + if onlineUsers, ok := metadata["onlineUsers"].([]any); ok { + for _, user := range onlineUsers { + if userStr, ok := user.(string); ok { + state.OnlineUsers = append(state.OnlineUsers, userStr) + } + } + } + if cursors, ok := metadata["activeCursors"].(map[string]any); ok { + for userID, cursorData := range cursors { + if cursorMap, ok := cursorData.(map[string]any); ok { + state.ActiveCursors[userID] = Position{X: numberValue(cursorMap["x"], 0), Y: numberValue(cursorMap["y"], 0)} + } + } + } + if lockedNodes, ok := metadata["lockedNodes"].([]any); ok { + for _, node := range lockedNodes { + if nodeStr, ok := node.(string); ok { + state.LockedNodes = append(state.LockedNodes, nodeStr) + } + } + } + if syncStatus, ok := metadata["syncStatus"].(string); ok { + state.SyncStatus = syncStatus + } + return state +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_intelligence_helpers.go b/backend/internal/app/cloud_agent_pi_canvas_intelligence_helpers.go new file mode 100644 index 000000000..781beb47a --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_intelligence_helpers.go @@ -0,0 +1,869 @@ +package app + +import ( + "fmt" + "math" + "regexp" + "sort" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// Canvas Intelligence 辅助方法 + +// isContextNode 判断节点是否为上下文节点(焦点节点的相邻节点) +func (ci *CanvasIntelligence) isContextNode(node model.CanvasNode, focusNodeIDs []string, allNodes []model.CanvasNode) bool { + focusMap := make(map[string]bool) + for _, id := range focusNodeIDs { + focusMap[id] = true + } + + // 检查是否是焦点节点的父节点 + if node.ParentID != nil && focusMap[*node.ParentID] { + return true + } + + // 检查是否是焦点节点的子节点 + for _, childID := range node.Children { + if focusMap[childID] { + return true + } + } + + // 检查是否在焦点节点的空间邻近范围内 + for _, focusID := range focusNodeIDs { + for _, n := range allNodes { + if n.ID == focusID && ci.isSpatiallyNear(node, n) { + return true + } + } + } + + return false +} + +// isSpatiallyNear 判断两个节点是否空间邻近 +func (ci *CanvasIntelligence) isSpatiallyNear(node1, node2 model.CanvasNode) bool { + if node1.Position == nil || node2.Position == nil { + return false + } + + // 计算距离 + dx := node1.Position.X - node2.Position.X + dy := node1.Position.Y - node2.Position.Y + distance := math.Sqrt(dx*dx + dy*dy) + + // 邻近阈值:200px + return distance < 200 +} + +// buildRelationships 构建关系视图 +func (ci *CanvasIntelligence) buildRelationships(nodes []model.CanvasNode) []RelationshipView { + relationships := make([]RelationshipView, 0) + nodeMap := make(map[string]model.CanvasNode) + + for _, node := range nodes { + nodeMap[node.ID] = node + } + + for _, node := range nodes { + // 父子关系 + if node.ParentID != nil { + relationships = append(relationships, RelationshipView{ + From: *node.ParentID, + To: node.ID, + Type: "parent", + Strength: 1.0, + Bidirection: false, + }) + } + + // 引用关系(从内容中提取) + refs := ci.extractReferences(node.Content) + for _, refID := range refs { + if _, exists := nodeMap[refID]; exists { + relationships = append(relationships, RelationshipView{ + From: node.ID, + To: refID, + Type: "reference", + Strength: 0.7, + Bidirection: false, + }) + } + } + + // 语义相似关系(简化实现) + for _, other := range nodes { + if node.ID != other.ID && ci.areSemanticallyRelated(node, other) { + relationships = append(relationships, RelationshipView{ + From: node.ID, + To: other.ID, + Type: "semantic", + Strength: 0.5, + Bidirection: true, + }) + } + } + } + + return relationships +} + +// extractReferences 从内容中提取引用(简化实现:查找 [[nodeId]] 模式) +func (ci *CanvasIntelligence) extractReferences(content string) []string { + re := regexp.MustCompile(`\[\[([a-zA-Z0-9-]+)\]\]`) + matches := re.FindAllStringSubmatch(content, -1) + refs := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) > 1 { + refs = append(refs, match[1]) + } + } + return refs +} + +// areSemanticallyRelated 判断两个节点是否语义相关(简化实现) +func (ci *CanvasIntelligence) areSemanticallyRelated(node1, node2 model.CanvasNode) bool { + // 简化实现:检查共同关键词 + keywords1 := ci.extractKeywords(node1.Content) + keywords2 := ci.extractKeywords(node2.Content) + + if len(keywords1) == 0 || len(keywords2) == 0 { + return false + } + + common := 0 + for _, k1 := range keywords1 { + for _, k2 := range keywords2 { + if k1 == k2 { + common++ + } + } + } + + // 共同关键词 >= 2 则认为相关 + return common >= 2 +} + +// analyzeLayout 分析布局 +func (ci *CanvasIntelligence) analyzeLayout(nodes []model.CanvasNode) LayoutAnalysis { + if len(nodes) == 0 { + return LayoutAnalysis{ + Algorithm: "empty", + Direction: "none", + Density: 0, + Clusters: []ClusterInfo{}, + Bounds: Bounds{}, + } + } + + // 计算边界 + bounds := ci.calculateBounds(nodes) + + // 检测布局算法 + algorithm := ci.detectLayoutAlgorithm(nodes) + + // 计算密度 + density := ci.calculateDensity(nodes, bounds) + + // 聚类分析 + clusters := ci.performClustering(nodes) + + // 推断方向 + direction := ci.inferDirection(nodes) + + return LayoutAnalysis{ + Algorithm: algorithm, + Direction: direction, + Density: density, + Clusters: clusters, + Bounds: bounds, + ViewportFit: ci.suggestViewportFit(bounds, density), + } +} + +// calculateBounds 计算边界 +func (ci *CanvasIntelligence) calculateBounds(nodes []model.CanvasNode) Bounds { + if len(nodes) == 0 { + return Bounds{} + } + + minX, minY := math.MaxFloat64, math.MaxFloat64 + maxX, maxY := -math.MaxFloat64, -math.MaxFloat64 + + for _, node := range nodes { + if node.Position != nil { + minX = math.Min(minX, node.Position.X) + minY = math.Min(minY, node.Position.Y) + maxX = math.Max(maxX, node.Position.X) + maxY = math.Max(maxY, node.Position.Y) + + if node.Size != nil { + maxX = math.Max(maxX, node.Position.X+node.Size.Width) + maxY = math.Max(maxY, node.Position.Y+node.Size.Height) + } + } + } + + return Bounds{ + MinX: minX, + MinY: minY, + MaxX: maxX, + MaxY: maxY, + } +} + +// detectLayoutAlgorithm 检测布局算法 +func (ci *CanvasIntelligence) detectLayoutAlgorithm(nodes []model.CanvasNode) string { + // 检查是否是树形布局(大部分节点有父节点) + withParent := 0 + for _, node := range nodes { + if node.ParentID != nil { + withParent++ + } + } + if float64(withParent)/float64(len(nodes)) > 0.6 { + return "tree" + } + + // 检查是否是网格布局(位置规则) + if ci.isGridLayout(nodes) { + return "grid" + } + + // 检查是否是力导向布局(分散均匀) + if ci.isForceLayout(nodes) { + return "force" + } + + return "freeform" +} + +// isGridLayout 判断是否是网格布局 +func (ci *CanvasIntelligence) isGridLayout(nodes []model.CanvasNode) bool { + if len(nodes) < 3 { + return false + } + + // 简化实现:检查X或Y坐标是否有明显对齐 + xCoords := make(map[float64]int) + yCoords := make(map[float64]int) + + for _, node := range nodes { + if node.Position != nil { + // 量化坐标(容忍10px误差) + xQuantized := math.Round(node.Position.X/10) * 10 + yQuantized := math.Round(node.Position.Y/10) * 10 + xCoords[xQuantized]++ + yCoords[yQuantized]++ + } + } + + // 如果有3个以上坐标重复,认为是网格 + for _, count := range xCoords { + if count >= 3 { + return true + } + } + for _, count := range yCoords { + if count >= 3 { + return true + } + } + + return false +} + +// isForceLayout 判断是否是力导向布局 +func (ci *CanvasIntelligence) isForceLayout(nodes []model.CanvasNode) bool { + // 简化实现:检查节点分布是否均匀 + if len(nodes) < 5 { + return false + } + + // 计算平均距离 + totalDist := 0.0 + count := 0 + for i, node1 := range nodes { + if node1.Position == nil { + continue + } + for j, node2 := range nodes { + if i >= j || node2.Position == nil { + continue + } + dx := node1.Position.X - node2.Position.X + dy := node1.Position.Y - node2.Position.Y + dist := math.Sqrt(dx*dx + dy*dy) + totalDist += dist + count++ + } + } + + if count == 0 { + return false + } + + avgDist := totalDist / float64(count) + + // 计算距离方差 + variance := 0.0 + count = 0 + for i, node1 := range nodes { + if node1.Position == nil { + continue + } + for j, node2 := range nodes { + if i >= j || node2.Position == nil { + continue + } + dx := node1.Position.X - node2.Position.X + dy := node1.Position.Y - node2.Position.Y + dist := math.Sqrt(dx*dx + dy*dy) + variance += (dist - avgDist) * (dist - avgDist) + count++ + } + } + + if count == 0 { + return false + } + + variance /= float64(count) + stdDev := math.Sqrt(variance) + + // 标准差/平均值 < 0.3 认为是均匀分布(力导向特征) + return stdDev/avgDist < 0.3 +} + +// calculateDensity 计算密度 +func (ci *CanvasIntelligence) calculateDensity(nodes []model.CanvasNode, bounds Bounds) float64 { + if len(nodes) == 0 { + return 0 + } + + area := (bounds.MaxX - bounds.MinX) * (bounds.MaxY - bounds.MinY) + if area == 0 { + return 0 + } + + totalNodeArea := 0.0 + for _, node := range nodes { + if node.Size != nil { + totalNodeArea += node.Size.Width * node.Size.Height + } else { + totalNodeArea += 100 * 100 // 默认大小 + } + } + + density := totalNodeArea / area + if density > 1 { + density = 1 + } + + return density +} + +// performClustering 执行聚类分析(简化K-means) +func (ci *CanvasIntelligence) performClustering(nodes []model.CanvasNode) []ClusterInfo { + // 过滤有位置的节点 + positioned := make([]model.CanvasNode, 0) + for _, node := range nodes { + if node.Position != nil { + positioned = append(positioned, node) + } + } + + if len(positioned) < 3 { + return []ClusterInfo{} + } + + // 简化实现:基于空间距离的简单聚类 + k := int(math.Min(5, float64(len(positioned)/3))) // 最多5个聚类 + if k < 2 { + k = 2 + } + + clusters := ci.simpleKMeans(positioned, k) + return clusters +} + +// simpleKMeans 简化的K-means聚类 +func (ci *CanvasIntelligence) simpleKMeans(nodes []model.CanvasNode, k int) []ClusterInfo { + if len(nodes) == 0 { + return []ClusterInfo{} + } + if k < 1 { + k = 1 + } + if len(nodes) < k { + k = len(nodes) + } + + // 初始化中心点(均匀分布) + centers := make([]Position, k) + step := len(nodes) / k + for i := 0; i < k; i++ { + idx := i * step + if idx >= len(nodes) { + idx = len(nodes) - 1 + } + centers[i] = Position{ + X: nodes[idx].Position.X, + Y: nodes[idx].Position.Y, + } + } + + // 迭代3次(简化) + for iter := 0; iter < 3; iter++ { + // 分配节点到最近的聚类 + assignments := make([]int, len(nodes)) + for i, node := range nodes { + minDist := math.MaxFloat64 + minCluster := 0 + for j, center := range centers { + dx := node.Position.X - center.X + dy := node.Position.Y - center.Y + dist := math.Sqrt(dx*dx + dy*dy) + if dist < minDist { + minDist = dist + minCluster = j + } + } + assignments[i] = minCluster + } + + // 更新中心点 + for j := 0; j < k; j++ { + sumX, sumY := 0.0, 0.0 + count := 0 + for i, assignment := range assignments { + if assignment == j { + sumX += nodes[i].Position.X + sumY += nodes[i].Position.Y + count++ + } + } + if count > 0 { + centers[j] = Position{ + X: sumX / float64(count), + Y: sumY / float64(count), + } + } + } + } + + // 构建聚类结果 + finalAssignments := make([]int, len(nodes)) + for i, node := range nodes { + minDist := math.MaxFloat64 + minCluster := 0 + for j, center := range centers { + dx := node.Position.X - center.X + dy := node.Position.Y - center.Y + dist := math.Sqrt(dx*dx + dy*dy) + if dist < minDist { + minDist = dist + minCluster = j + } + } + finalAssignments[i] = minCluster + } + + clusters := make([]ClusterInfo, k) + for j := 0; j < k; j++ { + nodeIDs := make([]string, 0) + for i, assignment := range finalAssignments { + if assignment == j { + nodeIDs = append(nodeIDs, nodes[i].ID) + } + } + + if len(nodeIDs) == 0 { + continue + } + + clusters[j] = ClusterInfo{ + ID: fmt.Sprintf("cluster-%d", j), + NodeIDs: nodeIDs, + Center: centers[j], + Label: ci.generateClusterLabel(nodes, nodeIDs), + Cohesion: ci.calculateCohesion(nodes, nodeIDs, centers[j]), + } + } + + // 过滤空聚类 + result := make([]ClusterInfo, 0) + for _, cluster := range clusters { + if len(cluster.NodeIDs) > 0 { + result = append(result, cluster) + } + } + + return result +} + +// generateClusterLabel 生成聚类标签 +func (ci *CanvasIntelligence) generateClusterLabel(allNodes []model.CanvasNode, clusterNodeIDs []string) string { + // 收集聚类中的关键词 + keywords := make(map[string]int) + for _, nodeID := range clusterNodeIDs { + for _, node := range allNodes { + if node.ID == nodeID { + for _, kw := range ci.extractKeywords(node.Content) { + keywords[kw]++ + } + } + } + } + + // 找到最频繁的关键词 + maxCount := 0 + topKeyword := "Cluster" + for kw, count := range keywords { + if count > maxCount { + maxCount = count + topKeyword = kw + } + } + + return topKeyword +} + +// calculateCohesion 计算内聚度 +func (ci *CanvasIntelligence) calculateCohesion(allNodes []model.CanvasNode, clusterNodeIDs []string, center Position) float64 { + if len(clusterNodeIDs) == 0 { + return 0 + } + + totalDist := 0.0 + count := 0 + for _, nodeID := range clusterNodeIDs { + for _, node := range allNodes { + if node.ID == nodeID && node.Position != nil { + dx := node.Position.X - center.X + dy := node.Position.Y - center.Y + dist := math.Sqrt(dx*dx + dy*dy) + totalDist += dist + count++ + } + } + } + + if count == 0 { + return 0 + } + + avgDist := totalDist / float64(count) + + // 归一化:距离越小,内聚度越高 + cohesion := 1.0 / (1.0 + avgDist/100.0) + return cohesion +} + +// inferDirection 推断方向 +func (ci *CanvasIntelligence) inferDirection(nodes []model.CanvasNode) string { + if len(nodes) < 2 { + return "none" + } + + // 统计父子关系的方向 + horizontalCount := 0 + verticalCount := 0 + + for _, node := range nodes { + if node.ParentID == nil || node.Position == nil { + continue + } + + // 找到父节点 + for _, parent := range nodes { + if parent.ID == *node.ParentID && parent.Position != nil { + dx := math.Abs(node.Position.X - parent.Position.X) + dy := math.Abs(node.Position.Y - parent.Position.Y) + + if dx > dy { + horizontalCount++ + } else { + verticalCount++ + } + } + } + } + + if horizontalCount > verticalCount { + return "horizontal" + } else if verticalCount > horizontalCount { + return "vertical" + } + + return "mixed" +} + +// suggestViewportFit 建议视口适配 +func (ci *CanvasIntelligence) suggestViewportFit(bounds Bounds, density float64) string { + width := bounds.MaxX - bounds.MinX + height := bounds.MaxY - bounds.MinY + + if width == 0 && height == 0 { + return "center" + } + + if density > 0.7 { + return "zoom-out" // 密度高,建议缩小 + } else if density < 0.2 { + return "zoom-in" // 密度低,建议放大 + } + + aspectRatio := width / height + if aspectRatio > 2 { + return "fit-width" + } else if aspectRatio < 0.5 { + return "fit-height" + } + + return "fit-all" +} + +// buildHierarchy 构建层级视图 +func (ci *CanvasIntelligence) buildHierarchy(nodes []model.CanvasNode) HierarchyView { + nodeMap := make(map[string]model.CanvasNode) + for _, node := range nodes { + nodeMap[node.ID] = node + } + + // 找到根节点 + rootNodes := make([]string, 0) + for _, node := range nodes { + if node.ParentID == nil { + rootNodes = append(rootNodes, node.ID) + } + } + + // 构建树 + tree := make(map[string]TreeNode) + maxDepth := 0 + + var buildTree func(nodeID string, depth int, path []string) + buildTree = func(nodeID string, depth int, path []string) { + if depth > maxDepth { + maxDepth = depth + } + + currentPath := append(path, nodeID) + node := nodeMap[nodeID] + + tree[nodeID] = TreeNode{ + ID: nodeID, + Depth: depth, + Children: node.Children, + Path: currentPath, + } + + for _, childID := range node.Children { + buildTree(childID, depth+1, currentPath) + } + } + + for _, rootID := range rootNodes { + buildTree(rootID, 0, []string{}) + } + + return HierarchyView{ + MaxDepth: maxDepth, + RootNodes: rootNodes, + Tree: tree, + } +} + +// extractKeywords 提取关键词(简化实现) +func (ci *CanvasIntelligence) extractKeywords(content string) []string { + // 移除常见停用词 + stopWords := map[string]bool{ + "the": true, "is": true, "at": true, "which": true, "on": true, + "a": true, "an": true, "and": true, "or": true, "but": true, + "in": true, "of": true, "to": true, "for": true, "with": true, + "这": true, "是": true, "在": true, "的": true, "了": true, + "和": true, "与": true, "或": true, "但": true, "可以": true, + } + + // 简单分词(按空格和标点) + re := regexp.MustCompile(`[^\w\p{Han}]+`) + words := re.Split(strings.ToLower(content), -1) + + // 统计词频 + freq := make(map[string]int) + for _, word := range words { + word = strings.TrimSpace(word) + if len(word) > 1 && !stopWords[word] { + freq[word]++ + } + } + + // 排序并取前5个 + type kv struct { + Key string + Value int + } + var sorted []kv + for k, v := range freq { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Value > sorted[j].Value + }) + + keywords := make([]string, 0, 5) + for i := 0; i < len(sorted) && i < 5; i++ { + keywords = append(keywords, sorted[i].Key) + } + + return keywords +} + +// categorizeNode 自动分类节点 +func (ci *CanvasIntelligence) categorizeNode(node model.CanvasNode) string { + content := strings.ToLower(node.Content) + + // 基于内容特征的简单分类 + if strings.Contains(content, "todo") || strings.Contains(content, "task") || strings.Contains(content, "待办") { + return "task" + } + if strings.Contains(content, "idea") || strings.Contains(content, "concept") || strings.Contains(content, "想法") || strings.Contains(content, "概念") { + return "idea" + } + if strings.Contains(content, "note") || strings.Contains(content, "memo") || strings.Contains(content, "笔记") { + return "note" + } + if strings.Contains(content, "question") || strings.Contains(content, "问题") || strings.Contains(content, "?") || strings.Contains(content, "?") { + return "question" + } + if strings.Contains(content, "decision") || strings.Contains(content, "决策") || strings.Contains(content, "结论") { + return "decision" + } + + // 基于类型 + switch node.Type { + case "code": + return "code" + case "image", "video", "audio": + return "media" + case "link": + return "reference" + case "diagram", "flowchart", "mindmap": + return "diagram" + default: + return "general" + } +} + +// analyzeSentiment 分析情感(简化实现) +func (ci *CanvasIntelligence) analyzeSentiment(content string) string { + content = strings.ToLower(content) + + // 正面词汇 + positiveWords := []string{"good", "great", "excellent", "success", "完成", "成功", "优秀", "很好", "✅", "✓"} + // 负面词汇 + negativeWords := []string{"bad", "error", "fail", "problem", "issue", "bug", "错误", "失败", "问题", "❌", "✗"} + // TODO词汇 + todoWords := []string{"todo", "task", "待办", "需要", "应该"} + + positiveScore := 0 + negativeScore := 0 + todoScore := 0 + + for _, word := range positiveWords { + if strings.Contains(content, word) { + positiveScore++ + } + } + for _, word := range negativeWords { + if strings.Contains(content, word) { + negativeScore++ + } + } + for _, word := range todoWords { + if strings.Contains(content, word) { + todoScore++ + } + } + + if todoScore > 0 { + return "todo" + } + if positiveScore > negativeScore { + return "positive" + } + if negativeScore > positiveScore { + return "negative" + } + + return "neutral" +} + +// convertPosition 转换位置 +func (ci *CanvasIntelligence) convertPosition(pos *model.CanvasPosition) *Position { + if pos == nil { + return nil + } + return &Position{X: pos.X, Y: pos.Y} +} + +// convertSize 转换尺寸 +func (ci *CanvasIntelligence) convertSize(size *model.CanvasSize) *Size { + if size == nil { + return nil + } + return &Size{Width: size.Width, Height: size.Height} +} + +// getZIndex 获取Z-index +func (ci *CanvasIntelligence) getZIndex(node model.CanvasNode) int { + if node.Metadata != nil { + if zIndex, ok := node.Metadata["zIndex"].(float64); ok { + return int(zIndex) + } + } + return 0 +} + +// getParentID 获取父节点ID +func (ci *CanvasIntelligence) getParentID(parentID *string) string { + if parentID == nil { + return "" + } + return *parentID +} + +// extractTags 提取标签 +func (ci *CanvasIntelligence) extractTags(node model.CanvasNode) []string { + if node.Metadata != nil { + if tags, ok := node.Metadata["tags"].([]interface{}); ok { + result := make([]string, 0, len(tags)) + for _, tag := range tags { + if s, ok := tag.(string); ok { + result = append(result, s) + } + } + return result + } + } + return []string{} +} + +// isRecentlyModified 判断是否最近被修改 +func (ci *CanvasIntelligence) isRecentlyModified(node model.CanvasNode) bool { + return time.Since(node.UpdatedAt) < 10*time.Minute +} + +// getNodeStatus 获取节点状态 +func (ci *CanvasIntelligence) getNodeStatus(node model.CanvasNode) string { + if node.Metadata != nil { + if status, ok := node.Metadata["status"].(string); ok { + return status + } + } + return "draft" +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_intelligence_test.go b/backend/internal/app/cloud_agent_pi_canvas_intelligence_test.go new file mode 100644 index 000000000..fa482c05c --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_intelligence_test.go @@ -0,0 +1,36 @@ +package app + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestCanvasIntelligenceUsesPersistedCanvasProject(t *testing.T) { + s, db, _, _ := creationTestService(t) + if err := db.Create(&model.CanvasProject{ + ID: "intelligence-canvas", + UserID: "user", + Title: "智能测试画布", + PayloadJSON: `{"description":"从持久化文档读取","nodes":[ + {"id":"n1","type":"text","title":"故事主题","content":"故事主题内容","position":{"x":10,"y":20}}, + {"id":"n2","type":"task","title":"下一步","parentId":"n1","metadata":{"status":"todo"}} + ],"connections":[{"fromNodeId":"n1","toNodeId":"n2","type":"flow","label":"推进"}]}`, + }).Error; err != nil { + t.Fatal(err) + } + + context, err := s.NewCanvasIntelligence().BuildEnhancedCanvasContext(t.Context(), "user", "intelligence-canvas", []string{"n1"}) + if err != nil { + t.Fatalf("build persisted canvas intelligence: %v", err) + } + if context.Name != "智能测试画布" || context.Description != "从持久化文档读取" { + t.Fatalf("unexpected canvas identity: %#v", context) + } + if context.Snapshot.TotalNodes != 2 || len(context.Snapshot.FocusNodes) != 1 || len(context.Snapshot.ContextNodes) != 1 { + t.Fatalf("unexpected node snapshot: %#v", context.Snapshot) + } + if len(context.Snapshot.Relationships) == 0 || context.Snapshot.Relationships[0].From != "n1" || context.Snapshot.Relationships[0].To != "n2" { + t.Fatalf("persisted connection was not projected: %#v", context.Snapshot.Relationships) + } +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_tools_handlers.go b/backend/internal/app/cloud_agent_pi_canvas_tools_handlers.go new file mode 100644 index 000000000..62421fa78 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_tools_handlers.go @@ -0,0 +1,818 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "log" + "time" + + "infinite-canvas/backend/internal/model" +) + +// Canvas Tool Handlers - 工具处理器实现 + +// handleNodeCreate 处理创建节点 +func (r *CanvasToolRegistry) handleNodeCreate(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + // 解析参数 + nodeType, _ := args["type"].(string) + content, _ := args["content"].(string) + + var position *model.CanvasPosition + if pos, ok := args["position"].(map[string]any); ok { + x, _ := pos["x"].(float64) + y, _ := pos["y"].(float64) + position = &model.CanvasPosition{X: x, Y: y} + } + + var size *model.CanvasSize + if sz, ok := args["size"].(map[string]any); ok { + width, _ := sz["width"].(float64) + height, _ := sz["height"].(float64) + size = &model.CanvasSize{Width: width, Height: height} + } + + var parentID *string + if pid, ok := args["parentId"].(string); ok && pid != "" { + parentID = &pid + } + + metadata, _ := args["metadata"].(map[string]interface{}) + + // 获取画布ID + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 创建节点 + node := model.CanvasNode{ + Type: nodeType, + Content: content, + Position: position, + Size: size, + ParentID: parentID, + Metadata: metadata, + } + + created, err := r.service.repo.CreateCanvasNode(userID, state.Request.CanvasID, node) + if err != nil { + return nil, fmt.Errorf("create node: %w", err) + } + + return map[string]any{ + "success": true, + "nodeId": created.ID, + "node": created, + "message": fmt.Sprintf("✅ 已创建 %s 节点:%s", nodeType, created.ID), + }, nil +} + +// handleNodeRead 处理读取节点 +func (r *CanvasToolRegistry) handleNodeRead(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + nodeID, _ := args["nodeId"].(string) + includeChildren, _ := args["includeChildren"].(bool) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + node, err := r.service.repo.CanvasNode(userID, state.Request.CanvasID, nodeID) + if err != nil { + return nil, fmt.Errorf("get node: %w", err) + } + + result := map[string]any{ + "success": true, + "node": node, + } + + if includeChildren && len(node.Children) > 0 { + children := make([]model.CanvasNode, 0, len(node.Children)) + for _, childID := range node.Children { + child, err := r.service.repo.CanvasNode(userID, state.Request.CanvasID, childID) + if err == nil { + children = append(children, child) + } + } + result["children"] = children + } + + return result, nil +} + +// handleNodeUpdate 处理更新节点 +func (r *CanvasToolRegistry) handleNodeUpdate(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + nodeID, _ := args["nodeId"].(string) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 构建更新对象 + update := model.CanvasNodeUpdate{} + + if content, ok := args["content"].(string); ok { + update.Content = &content + } + + if pos, ok := args["position"].(map[string]any); ok { + x, _ := pos["x"].(float64) + y, _ := pos["y"].(float64) + update.Position = &model.CanvasPosition{X: x, Y: y} + } + + if sz, ok := args["size"].(map[string]any); ok { + width, _ := sz["width"].(float64) + height, _ := sz["height"].(float64) + update.Size = &model.CanvasSize{Width: width, Height: height} + } + + if metadata, ok := args["metadata"].(map[string]interface{}); ok { + update.Metadata = metadata + } + + updated, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update) + if err != nil { + return nil, fmt.Errorf("update node: %w", err) + } + + return map[string]any{ + "success": true, + "node": updated, + "message": fmt.Sprintf("✅ 已更新节点:%s", nodeID), + }, nil +} + +// handleNodeDelete 处理删除节点 +func (r *CanvasToolRegistry) handleNodeDelete(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + nodeID, _ := args["nodeId"].(string) + reason, _ := args["reason"].(string) + recursive, _ := args["recursive"].(bool) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 删除节点 + if err := r.service.repo.DeleteCanvasNode(userID, state.Request.CanvasID, nodeID, recursive); err != nil { + return nil, fmt.Errorf("delete node: %w", err) + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("✅ 已删除节点:%s(原因:%s)", nodeID, reason), + }, nil +} + +// handleNodeMove 处理移动节点 +func (r *CanvasToolRegistry) handleNodeMove(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + nodeID, _ := args["nodeId"].(string) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + update := model.CanvasNodeUpdate{} + + // 更新位置 + if pos, ok := args["position"].(map[string]any); ok { + x, _ := pos["x"].(float64) + y, _ := pos["y"].(float64) + update.Position = &model.CanvasPosition{X: x, Y: y} + } + + // 更新父节点 + if newParentID, ok := args["newParentId"].(string); ok { + if newParentID == "" { + emptyParentID := "" + update.ParentID = &emptyParentID + } else { + update.ParentID = &newParentID + } + } + + updated, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update) + if err != nil { + return nil, fmt.Errorf("move node: %w", err) + } + + return map[string]any{ + "success": true, + "node": updated, + "message": fmt.Sprintf("✅ 已移动节点:%s", nodeID), + }, nil +} + +// handleNodeDuplicate 处理复制节点 +func (r *CanvasToolRegistry) handleNodeDuplicate(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + nodeID, _ := args["nodeId"].(string) + includeChildren, _ := args["includeChildren"].(bool) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 读取原节点 + original, err := r.service.repo.CanvasNode(userID, state.Request.CanvasID, nodeID) + if err != nil { + return nil, fmt.Errorf("get original node: %w", err) + } + + // 创建副本 + duplicate := model.CanvasNode{ + Type: original.Type, + Content: original.Content + " (副本)", + Position: original.Position, + Size: original.Size, + ParentID: original.ParentID, + Metadata: original.Metadata, + } + + // 调整位置(偏移) + if duplicate.Position != nil { + duplicate.Position.X += 50 + duplicate.Position.Y += 50 + } + + // 如果指定了新位置 + if pos, ok := args["position"].(map[string]any); ok { + x, _ := pos["x"].(float64) + y, _ := pos["y"].(float64) + duplicate.Position = &model.CanvasPosition{X: x, Y: y} + } + + created, err := r.service.repo.CreateCanvasNode(userID, state.Request.CanvasID, duplicate) + if err != nil { + return nil, fmt.Errorf("create duplicate: %w", err) + } + + // 递归复制子节点 + if includeChildren { + if err := r.duplicateChildrenRecursive(userID, state.Request.CanvasID, nodeID, created.ID); err != nil { + log.Printf("[Agent] failed to duplicate children: %v", err) + // 不中断,继续返回主节点的复制结果 + } + } + + return map[string]any{ + "success": true, + "originalId": nodeID, + "duplicateId": created.ID, + "node": created, + "childrenCopied": includeChildren, + "message": fmt.Sprintf("✅ 已复制节点:%s -> %s", nodeID, created.ID), + }, nil +} + +// duplicateChildrenRecursive 递归复制子节点 +func (r *CanvasToolRegistry) duplicateChildrenRecursive(userID, canvasID, parentID, newParentID string) error { + // 获取原始父节点的所有子节点 + children, err := r.service.repo.GetCanvasNodeChildren(userID, canvasID, parentID) + if err != nil { + return fmt.Errorf("get children of %s: %w", parentID, err) + } + + for _, child := range children { + // 复制子节点 + duplicate := child + duplicate.ID = "" // 清空 ID,让数据库生成新的 + duplicate.ParentID = &newParentID + + // 调整位置(稍微偏移) + if duplicate.Position != nil { + duplicate.Position.X += 10 + duplicate.Position.Y += 10 + } + + created, err := r.service.repo.CreateCanvasNode(userID, canvasID, duplicate) + if err != nil { + log.Printf("[Agent] failed to duplicate child %s: %v", child.ID, err) + continue + } + + // 递归复制这个子节点的子节点 + if err := r.duplicateChildrenRecursive(userID, canvasID, child.ID, created.ID); err != nil { + log.Printf("[Agent] failed to duplicate descendants of %s: %v", child.ID, err) + } + } + + return nil +} + +// handleRelationshipCreate 处理创建关系 +func (r *CanvasToolRegistry) handleRelationshipCreate(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + fromNodeID, _ := args["fromNodeId"].(string) + toNodeID, _ := args["toNodeId"].(string) + relType, _ := args["type"].(string) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 创建关系 + relationship := model.CanvasRelationship{ + From: fromNodeID, + To: toNodeID, + Type: relType, + } + + if err := r.service.repo.CreateCanvasRelationship(userID, state.Request.CanvasID, relationship); err != nil { + return nil, fmt.Errorf("create relationship: %w", err) + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("✅ 已创建关系:%s -> %s (%s)", fromNodeID, toNodeID, relType), + }, nil +} + +// handleRelationshipDelete 处理删除关系 +func (r *CanvasToolRegistry) handleRelationshipDelete(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + fromNodeID, _ := args["fromNodeId"].(string) + toNodeID, _ := args["toNodeId"].(string) + relType, _ := args["type"].(string) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + if err := r.service.repo.DeleteCanvasRelationship(userID, state.Request.CanvasID, fromNodeID, toNodeID, relType); err != nil { + return nil, fmt.Errorf("delete relationship: %w", err) + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("✅ 已删除关系:%s -> %s", fromNodeID, toNodeID), + }, nil +} + +// handleLayout 处理自动布局 +func (r *CanvasToolRegistry) handleLayout(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + algorithm, _ := args["algorithm"].(string) + direction, _ := args["direction"].(string) + spacing, _ := args["spacing"].(float64) + + if spacing == 0 { + spacing = 100 + } + + var nodeIDs []string + if ids, ok := args["nodeIds"].([]interface{}); ok { + for _, id := range ids { + if s, ok := id.(string); ok { + nodeIDs = append(nodeIDs, s) + } + } + } + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 获取要布局的节点 + var nodes []model.CanvasNode + if len(nodeIDs) > 0 { + for _, nodeID := range nodeIDs { + node, err := r.service.repo.CanvasNode(userID, state.Request.CanvasID, nodeID) + if err == nil { + nodes = append(nodes, node) + } + } + } else { + nodes, err = r.service.repo.CanvasNodes(userID, state.Request.CanvasID) + if err != nil { + return nil, fmt.Errorf("get nodes: %w", err) + } + } + + // 执行布局算法 + layoutEngine := NewCanvasLayoutEngine(r.service) + newPositions, err := layoutEngine.ApplyLayout(algorithm, direction, spacing, nodes) + if err != nil { + return nil, fmt.Errorf("apply layout: %w", err) + } + + // 批量更新节点位置 + updatedCount := 0 + for nodeID, position := range newPositions { + update := model.CanvasNodeUpdate{ + Position: position, + } + if _, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update); err == nil { + updatedCount++ + } + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("✅ 已应用 %s 布局,更新 %d 个节点", algorithm, updatedCount), + "updated": updatedCount, + }, nil +} + +// handleSearch 处理搜索 +func (r *CanvasToolRegistry) handleSearch(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + query, _ := args["query"].(string) + nodeType, _ := args["type"].(string) + category, _ := args["category"].(string) + limit, _ := args["limit"].(float64) + + if limit == 0 { + limit = 20 + } + + var tags []string + if t, ok := args["tags"].([]interface{}); ok { + for _, tag := range t { + if s, ok := tag.(string); ok { + tags = append(tags, s) + } + } + } + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + // 搜索节点 + searchEngine := NewCanvasSearchEngine(r.service) + results, err := searchEngine.Search(userID, state.Request.CanvasID, CanvasSearchQuery{ + Query: query, + Type: nodeType, + Category: category, + Tags: tags, + Limit: int(limit), + }) + if err != nil { + return nil, fmt.Errorf("search: %w", err) + } + + return map[string]any{ + "success": true, + "results": results, + "count": len(results), + "message": fmt.Sprintf("找到 %d 个匹配节点", len(results)), + }, nil +} + +// handleBulkOperation 处理批量操作 +func (r *CanvasToolRegistry) handleBulkOperation(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + operation, _ := args["operation"].(string) + reason, _ := args["reason"].(string) + params, _ := args["params"].(map[string]any) + + var nodeIDs []string + if ids, ok := args["nodeIds"].([]interface{}); ok { + for _, id := range ids { + if s, ok := id.(string); ok { + nodeIDs = append(nodeIDs, s) + } + } + } + + if len(nodeIDs) == 0 { + return nil, fmt.Errorf("no node IDs provided") + } + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + successCount := 0 + failCount := 0 + + switch operation { + case "update": + update := model.CanvasNodeUpdate{} + if content, ok := params["content"].(string); ok { + update.Content = &content + } + if metadata, ok := params["metadata"].(map[string]interface{}); ok { + update.Metadata = metadata + } + + for _, nodeID := range nodeIDs { + if _, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update); err == nil { + successCount++ + } else { + failCount++ + } + } + + case "delete": + for _, nodeID := range nodeIDs { + if err := r.service.repo.DeleteCanvasNode(userID, state.Request.CanvasID, nodeID, false); err == nil { + successCount++ + } else { + failCount++ + } + } + + case "move": + var position *model.CanvasPosition + if pos, ok := params["position"].(map[string]any); ok { + x, _ := pos["x"].(float64) + y, _ := pos["y"].(float64) + position = &model.CanvasPosition{X: x, Y: y} + } + + for i, nodeID := range nodeIDs { + update := model.CanvasNodeUpdate{ + Position: position, + } + // 为每个节点稍微偏移位置 + if position != nil && i > 0 { + offsetPos := &model.CanvasPosition{ + X: position.X + float64(i*20), + Y: position.Y + float64(i*20), + } + update.Position = offsetPos + } + + if _, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update); err == nil { + successCount++ + } else { + failCount++ + } + } + + case "tag": + if tags, ok := params["tags"].([]interface{}); ok { + tagStrings := make([]string, 0, len(tags)) + for _, tag := range tags { + if s, ok := tag.(string); ok { + tagStrings = append(tagStrings, s) + } + } + + for _, nodeID := range nodeIDs { + node, err := r.service.repo.CanvasNode(userID, state.Request.CanvasID, nodeID) + if err != nil { + failCount++ + continue + } + + if node.Metadata == nil { + node.Metadata = make(map[string]interface{}) + } + node.Metadata["tags"] = tagStrings + + update := model.CanvasNodeUpdate{ + Metadata: node.Metadata, + } + if _, err := r.service.repo.UpdateCanvasNode(userID, state.Request.CanvasID, nodeID, update); err == nil { + successCount++ + } else { + failCount++ + } + } + } + + default: + return nil, fmt.Errorf("unsupported operation: %s", operation) + } + + return map[string]any{ + "success": true, + "operation": operation, + "successCount": successCount, + "failCount": failCount, + "message": fmt.Sprintf("✅ 批量%s完成:成功 %d,失败 %d(原因:%s)", operation, successCount, failCount, reason), + }, nil +} + +// handleSnapshot 处理获取快照 +func (r *CanvasToolRegistry) handleSnapshot(ctx context.Context, userID, runID string, args map[string]any) (any, error) { + includeIntelligence, _ := args["includeIntelligence"].(bool) + if _, ok := args["includeIntelligence"]; !ok { + includeIntelligence = true // 默认包含智能分析 + } + + var focusNodeIDs []string + if ids, ok := args["focusNodeIds"].([]interface{}); ok { + for _, id := range ids { + if s, ok := id.(string); ok { + focusNodeIDs = append(focusNodeIDs, s) + } + } + } + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + if includeIntelligence { + // 使用完整的智能上下文 + canvasIntelligence := r.service.NewCanvasIntelligence() + enhancedCanvas, err := canvasIntelligence.BuildEnhancedCanvasContext(ctx, userID, state.Request.CanvasID, focusNodeIDs) + if err != nil { + return nil, fmt.Errorf("build intelligence: %w", err) + } + + return map[string]any{ + "success": true, + "snapshot": enhancedCanvas.Snapshot, + "intelligence": enhancedCanvas.Intelligence, + "timestamp": time.Now().Unix(), + }, nil + } + + // 简单快照(不含智能分析) + nodes, err := r.service.repo.CanvasNodes(userID, state.Request.CanvasID) + if err != nil { + return nil, fmt.Errorf("get nodes: %w", err) + } + + return map[string]any{ + "success": true, + "nodeCount": len(nodes), + "nodes": nodes, + "timestamp": time.Now().Unix(), + }, nil +} + +// 辅助方法 + +func (r *CanvasToolRegistry) validatePermissions(userID string, requiredPermissions []string) error { + // 实现真实的权限检查 + if len(requiredPermissions) == 0 { + return nil + } + + // 从用户服务获取用户权限 + user, err := r.service.repo.User(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if user.Role == model.UserRoleAdmin { + return nil + } + return fmt.Errorf("permission denied: user role %s does not grant Agent canvas permissions", user.Role) +} + +func (r *CanvasToolRegistry) validateParameters(tool *CanvasToolDefinition, args map[string]any) error { + // 实现参数验证(基于 JSON Schema) + if tool.Schema == nil { + return nil + } + + // 检查必需参数 + if required, ok := tool.Schema["required"].([]any); ok { + for _, req := range required { + if reqStr, ok := req.(string); ok { + if _, exists := args[reqStr]; !exists { + return fmt.Errorf("missing required parameter: %s", reqStr) + } + } + } + } + + // 检查参数类型 + if properties, ok := tool.Schema["properties"].(map[string]any); ok { + for paramName, paramValue := range args { + if propSchema, exists := properties[paramName]; exists { + if schemaMap, ok := propSchema.(map[string]any); ok { + if err := r.validateParameterType(paramName, paramValue, schemaMap); err != nil { + return err + } + } + } + } + } + + return nil +} + +func (r *CanvasToolRegistry) validateParameterType(name string, value any, schema map[string]any) error { + expectedType, _ := schema["type"].(string) + + switch expectedType { + case "string": + if _, ok := value.(string); !ok { + return fmt.Errorf("parameter %s must be a string", name) + } + case "number": + switch value.(type) { + case float64, int, int64, float32: + // OK + default: + return fmt.Errorf("parameter %s must be a number", name) + } + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("parameter %s must be a boolean", name) + } + case "object": + if _, ok := value.(map[string]any); !ok { + return fmt.Errorf("parameter %s must be an object", name) + } + case "array": + if _, ok := value.([]any); !ok { + return fmt.Errorf("parameter %s must be an array", name) + } + } + + return nil +} + +func (r *CanvasToolRegistry) checkApproval(userID, runID, toolName string, args map[string]any) (bool, error) { + // 实现审批检查逻辑 + // 检查是否已经有批准记录 + + // 从数据库查询审批记录 + approvalKey := fmt.Sprintf("approval:%s:%s:%s", runID, toolName, hashArgs(args)) + + run, err := r.service.repo.CloudAgent(userID, runID) + if err != nil { + return false, fmt.Errorf("get run: %w", err) + } + + state, err := cloudAgentDecode(run) + if err != nil { + return false, fmt.Errorf("load state: %w", err) + } + if state.Decisions != nil && state.Decisions[approvalKey] == "approved" { + return true, nil + } + return false, nil +} + +// hashArgs 生成参数的哈希值,用于审批记录的唯一标识 +func hashArgs(args map[string]any) string { + data, _ := json.Marshal(args) + return fmt.Sprintf("%x", sha256.Sum256(data))[:16] +} diff --git a/backend/internal/app/cloud_agent_pi_canvas_tools_registry.go b/backend/internal/app/cloud_agent_pi_canvas_tools_registry.go new file mode 100644 index 000000000..d1f484595 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_canvas_tools_registry.go @@ -0,0 +1,573 @@ +package app + +import ( + "context" + "fmt" +) + +// 画布工具注册表: +// 1. 可扩展:新增工具只需注册,无需修改核心代码 +// 2. 类型安全:每个工具有明确的参数和返回类型定义 +// 3. 自动发现:工具定义自动生成 OpenAPI schema +// 4. 权限控制:每个工具可以配置权限和审批要求 + +// CanvasToolRegistry 画布工具注册表 +type CanvasToolRegistry struct { + service *Service + tools map[string]*CanvasToolDefinition +} + +// CanvasToolDefinition 画布工具定义 +type CanvasToolDefinition struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Category string `json:"category"` // node/relationship/layout/search/bulk + Parameters map[string]any `json:"parameters"` + Schema map[string]any `json:"schema,omitempty"` + RequireApproval bool `json:"requireApproval"` + Permissions []string `json:"permissions"` + Examples []map[string]any `json:"examples,omitempty"` + Handler CanvasToolHandler `json:"-"` +} + +// CanvasToolHandler 工具处理器 +type CanvasToolHandler func(ctx context.Context, userID, runID string, args map[string]any) (any, error) + +// NewCanvasToolRegistry 创建工具注册表 +func (s *Service) NewCanvasToolRegistry() *CanvasToolRegistry { + registry := &CanvasToolRegistry{ + service: s, + tools: make(map[string]*CanvasToolDefinition), + } + + // 注册所有画布工具 + registry.registerAllTools() + + return registry +} + +// registerAllTools 注册所有工具 +func (r *CanvasToolRegistry) registerAllTools() { + // 节点操作工具 + r.registerNodeTools() + + // 关系操作工具 + r.registerRelationshipTools() + + // 布局工具 + r.registerLayoutTools() + + // 搜索工具 + r.registerSearchTools() + + // 批量操作工具 + r.registerBulkTools() + + // 快照工具 + r.registerSnapshotTools() +} + +// registerNodeTools 注册节点工具 +func (r *CanvasToolRegistry) registerNodeTools() { + // 1. 创建节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_create", + DisplayName: "创建画布节点", + Description: "在画布上创建一个新节点。支持多种节点类型(text/markdown/code/image/diagram等),可以指定位置、大小、父节点等属性。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "节点类型:text/markdown/code/image/video/link/diagram/mindmap/task等", + "enum": []string{"text", "markdown", "code", "image", "video", "audio", "link", "diagram", "mindmap", "flowchart", "task", "note"}, + }, + "content": map[string]any{ + "type": "string", + "description": "节点内容", + }, + "position": map[string]any{ + "type": "object", + "description": "节点位置 {x: number, y: number}", + "properties": map[string]any{ + "x": map[string]any{"type": "number"}, + "y": map[string]any{"type": "number"}, + }, + }, + "size": map[string]any{ + "type": "object", + "description": "节点尺寸 {width: number, height: number}", + "properties": map[string]any{ + "width": map[string]any{"type": "number"}, + "height": map[string]any{"type": "number"}, + }, + }, + "parentId": map[string]any{ + "type": "string", + "description": "父节点ID(可选)", + }, + "metadata": map[string]any{ + "type": "object", + "description": "额外元数据(可选)", + }, + }, + "required": []string{"type", "content"}, + }, + RequireApproval: false, + Permissions: []string{"canCreate"}, + Examples: []map[string]any{ + { + "type": "text", + "content": "这是一个文本节点", + "position": map[string]any{ + "x": 100, + "y": 200, + }, + }, + { + "type": "task", + "content": "完成代码审查", + "parentId": "parent-node-id", + }, + }, + Handler: r.handleNodeCreate, + }) + + // 2. 读取节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_read", + DisplayName: "读取画布节点", + Description: "读取指定节点的完整信息,可以选择是否包含子节点。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "节点ID", + }, + "includeChildren": map[string]any{ + "type": "boolean", + "description": "是否包含子节点(默认false)", + }, + }, + "required": []string{"nodeId"}, + }, + RequireApproval: false, + Permissions: []string{"canRead"}, + Handler: r.handleNodeRead, + }) + + // 3. 更新节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_update", + DisplayName: "更新画布节点", + Description: "更新节点的内容、位置、尺寸或元数据。只需要提供要更新的字段。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "节点ID", + }, + "content": map[string]any{ + "type": "string", + "description": "新的内容(可选)", + }, + "position": map[string]any{ + "type": "object", + "description": "新的位置(可选)", + }, + "size": map[string]any{ + "type": "object", + "description": "新的尺寸(可选)", + }, + "metadata": map[string]any{ + "type": "object", + "description": "新的元数据(可选)", + }, + }, + "required": []string{"nodeId"}, + }, + RequireApproval: false, + Permissions: []string{"canUpdate"}, + Handler: r.handleNodeUpdate, + }) + + // 4. 删除节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_delete", + DisplayName: "删除画布节点", + Description: "删除指定节点。如果节点有子节点,可以选择是否同时删除。此操作需要用户审批。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "节点ID", + }, + "reason": map[string]any{ + "type": "string", + "description": "删除原因", + }, + "recursive": map[string]any{ + "type": "boolean", + "description": "是否递归删除子节点(默认false)", + }, + }, + "required": []string{"nodeId", "reason"}, + }, + RequireApproval: true, + Permissions: []string{"canDelete"}, + Handler: r.handleNodeDelete, + }) + + // 5. 移动节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_move", + DisplayName: "移动画布节点", + Description: "移动节点到新位置,或改变节点的父子关系。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "节点ID", + }, + "position": map[string]any{ + "type": "object", + "description": "新位置 {x: number, y: number}(可选)", + }, + "newParentId": map[string]any{ + "type": "string", + "description": "新父节点ID(可选,null表示移到根层级)", + }, + }, + "required": []string{"nodeId"}, + }, + RequireApproval: false, + Permissions: []string{"canMove"}, + Handler: r.handleNodeMove, + }) + + // 6. 复制节点 + r.Register(&CanvasToolDefinition{ + Name: "canvas_node_duplicate", + DisplayName: "复制画布节点", + Description: "复制一个节点,可以选择是否复制子节点。", + Category: "node", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "要复制的节点ID", + }, + "includeChildren": map[string]any{ + "type": "boolean", + "description": "是否复制子节点(默认false)", + }, + "position": map[string]any{ + "type": "object", + "description": "新节点位置(可选)", + }, + }, + "required": []string{"nodeId"}, + }, + RequireApproval: false, + Permissions: []string{"canCreate"}, + Handler: r.handleNodeDuplicate, + }) +} + +// registerRelationshipTools 注册关系工具 +func (r *CanvasToolRegistry) registerRelationshipTools() { + // 1. 创建关系 + r.Register(&CanvasToolDefinition{ + Name: "canvas_relationship_create", + DisplayName: "创建节点关系", + Description: "在两个节点之间建立关系。支持多种关系类型(reference/dependency/flow/semantic)。", + Category: "relationship", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "fromNodeId": map[string]any{ + "type": "string", + "description": "源节点ID", + }, + "toNodeId": map[string]any{ + "type": "string", + "description": "目标节点ID", + }, + "type": map[string]any{ + "type": "string", + "description": "关系类型", + "enum": []string{"reference", "dependency", "flow", "semantic", "custom"}, + }, + "bidirection": map[string]any{ + "type": "boolean", + "description": "是否双向(默认false)", + }, + "label": map[string]any{ + "type": "string", + "description": "关系标签(可选)", + }, + }, + "required": []string{"fromNodeId", "toNodeId", "type"}, + }, + RequireApproval: false, + Permissions: []string{"canManageRelations"}, + Handler: r.handleRelationshipCreate, + }) + + // 2. 删除关系 + r.Register(&CanvasToolDefinition{ + Name: "canvas_relationship_delete", + DisplayName: "删除节点关系", + Description: "删除两个节点之间的关系。", + Category: "relationship", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "fromNodeId": map[string]any{ + "type": "string", + "description": "源节点ID", + }, + "toNodeId": map[string]any{ + "type": "string", + "description": "目标节点ID", + }, + "type": map[string]any{ + "type": "string", + "description": "关系类型(可选,不指定则删除所有关系)", + }, + }, + "required": []string{"fromNodeId", "toNodeId"}, + }, + RequireApproval: false, + Permissions: []string{"canManageRelations"}, + Handler: r.handleRelationshipDelete, + }) +} + +// registerLayoutTools 注册布局工具 +func (r *CanvasToolRegistry) registerLayoutTools() { + r.Register(&CanvasToolDefinition{ + Name: "canvas_layout", + DisplayName: "画布自动布局", + Description: "对画布或指定节点组进行自动布局。支持多种布局算法(tree/grid/force/circular)。", + Category: "layout", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "algorithm": map[string]any{ + "type": "string", + "description": "布局算法", + "enum": []string{"tree", "grid", "force", "circular", "hierarchical", "auto"}, + }, + "nodeIds": map[string]any{ + "type": "array", + "description": "要布局的节点ID列表(可选,不指定则布局全部)", + "items": map[string]any{ + "type": "string", + }, + }, + "direction": map[string]any{ + "type": "string", + "description": "布局方向(tree/hierarchical算法适用)", + "enum": []string{"horizontal", "vertical"}, + }, + "spacing": map[string]any{ + "type": "number", + "description": "节点间距(默认100)", + }, + }, + "required": []string{"algorithm"}, + }, + RequireApproval: false, + Permissions: []string{"canMove"}, + Handler: r.handleLayout, + }) +} + +// registerSearchTools 注册搜索工具 +func (r *CanvasToolRegistry) registerSearchTools() { + r.Register(&CanvasToolDefinition{ + Name: "canvas_search", + DisplayName: "搜索画布节点", + Description: "根据关键词、类型、标签等条件搜索画布节点。支持全文搜索和过滤。", + Category: "search", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "搜索关键词(可选)", + }, + "type": map[string]any{ + "type": "string", + "description": "节点类型过滤(可选)", + }, + "tags": map[string]any{ + "type": "array", + "description": "标签过滤(可选)", + "items": map[string]any{ + "type": "string", + }, + }, + "category": map[string]any{ + "type": "string", + "description": "分类过滤(可选)", + }, + "limit": map[string]any{ + "type": "number", + "description": "返回结果数量限制(默认20)", + }, + }, + }, + RequireApproval: false, + Permissions: []string{"canRead"}, + Handler: r.handleSearch, + }) +} + +// registerBulkTools 注册批量工具 +func (r *CanvasToolRegistry) registerBulkTools() { + r.Register(&CanvasToolDefinition{ + Name: "canvas_bulk_operation", + DisplayName: "批量操作节点", + Description: "对多个节点执行批量操作(更新/删除/移动)。此操作需要用户审批。", + Category: "bulk", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "operation": map[string]any{ + "type": "string", + "description": "操作类型", + "enum": []string{"update", "delete", "move", "tag"}, + }, + "nodeIds": map[string]any{ + "type": "array", + "description": "节点ID列表", + "items": map[string]any{ + "type": "string", + }, + }, + "params": map[string]any{ + "type": "object", + "description": "操作参数", + }, + "reason": map[string]any{ + "type": "string", + "description": "操作原因", + }, + }, + "required": []string{"operation", "nodeIds", "reason"}, + }, + RequireApproval: true, + Permissions: []string{"canUpdate", "canDelete", "canMove"}, + Handler: r.handleBulkOperation, + }) +} + +// registerSnapshotTools 注册快照工具 +func (r *CanvasToolRegistry) registerSnapshotTools() { + r.Register(&CanvasToolDefinition{ + Name: "canvas_snapshot", + DisplayName: "获取画布快照", + Description: "获取当前画布的结构化快照,包括节点、关系、布局分析、智能洞察等。", + Category: "snapshot", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "includeIntelligence": map[string]any{ + "type": "boolean", + "description": "是否包含智能分析(默认true)", + }, + "focusNodeIds": map[string]any{ + "type": "array", + "description": "焦点节点ID列表(可选)", + "items": map[string]any{ + "type": "string", + }, + }, + }, + }, + RequireApproval: false, + Permissions: []string{"canRead"}, + Handler: r.handleSnapshot, + }) +} + +// Register 注册工具 +func (r *CanvasToolRegistry) Register(tool *CanvasToolDefinition) { + if tool.Schema == nil { + tool.Schema = tool.Parameters + } + r.tools[tool.Name] = tool +} + +// GetToolDefinition 获取工具定义 +func (r *CanvasToolRegistry) GetToolDefinition(name string) (*CanvasToolDefinition, bool) { + tool, ok := r.tools[name] + return tool, ok +} + +// GetAllToolDefinitions 获取所有工具定义 +func (r *CanvasToolRegistry) GetAllToolDefinitions() []map[string]any { + result := make([]map[string]any, 0, len(r.tools)) + + for _, tool := range r.tools { + result = append(result, map[string]any{ + "name": tool.Name, + "displayName": tool.DisplayName, + "description": tool.Description, + "category": tool.Category, + "parameters": tool.Parameters, + "requireApproval": tool.RequireApproval, + "permissions": tool.Permissions, + "examples": tool.Examples, + }) + } + + return result +} + +// ExecuteTool 执行工具 +func (r *CanvasToolRegistry) ExecuteTool(ctx context.Context, userID, runID, toolName string, args map[string]any) (any, error) { + tool, ok := r.GetToolDefinition(toolName) + if !ok { + return nil, fmt.Errorf("unknown tool: %s", toolName) + } + + // 验证权限 + if err := r.validatePermissions(userID, tool.Permissions); err != nil { + return nil, fmt.Errorf("permission denied: %w", err) + } + + // 验证参数 + if err := r.validateParameters(tool, args); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + + // 检查是否需要审批 + if tool.RequireApproval { + if approved, err := r.checkApproval(userID, runID, toolName, args); err != nil || !approved { + return map[string]any{ + "requiresApproval": true, + "status": "pending_approval", + "message": "此操作需要用户审批", + }, nil + } + } + + // 执行工具 + return tool.Handler(ctx, userID, runID, args) +} + +// 工具处理器实现将在下一个文件中 diff --git a/backend/internal/app/cloud_agent_pi_contract_test.go b/backend/internal/app/cloud_agent_pi_contract_test.go new file mode 100644 index 000000000..3fe13b055 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_contract_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCloudAgentProductionSchedulingDoesNotCallLegacyGoLoop(t *testing.T) { + root := filepath.Join("..", "..") + files := []string{ + filepath.Join(root, "internal", "app", "cloud_agent.go"), + filepath.Join(root, "internal", "app", "cloud_agent_runtime.go"), + filepath.Join(root, "internal", "app", "task_worker.go"), + } + for _, file := range files { + body, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + text := string(body) + for _, forbidden := range []string{ + "s.advanceCloudAgentByID(", + "s.advanceCloudAgents()", + } { + if strings.Contains(text, forbidden) { + t.Fatalf("production scheduling still calls legacy Go loop: %s in %s", forbidden, file) + } + } + } +} + +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) + } + text := string(coordinator) + for _, required := range []string{ + "Operation: cloudAgentStepOperation", + "s.enqueueCloudAgentTask(run, &state, req, nil)", + "s.waitCloudAgentTask(ctx, taskID)", + } { + if !strings.Contains(text, required) { + t.Fatalf("Pi governed model bridge contract missing %q", required) + } + } + runtime, err := os.ReadFile(filepath.Join(root, "internal", "agent", "runtime", "runtime.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(runtime), "mux.HandleFunc(\"POST /model\"") { + t.Fatal("Pi runtime does not expose the governed /model bridge") + } +} diff --git a/backend/internal/app/cloud_agent_pi_coordinator.go b/backend/internal/app/cloud_agent_pi_coordinator.go new file mode 100644 index 000000000..eb43a7a60 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_coordinator.go @@ -0,0 +1,1289 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "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 == "" { + return + } + + s.piRunnerMu.Lock() + if s.piRunners == nil { + s.piRunners = make(map[string]context.CancelFunc) + } + if s.piRunnersClosed { + s.piRunnerMu.Unlock() + return + } + + // Recovery scans run periodically, and create/idempotency paths may also + // request a start. A duplicate request must not cancel and restart the live + // Pi session: both processes would then write the same run checkpoint and + // session file concurrently. + if _, exists := s.piRunners[runID]; exists { + s.piRunnerMu.Unlock() + return + } + + ctx, cancel := context.WithCancel(context.Background()) + s.piRunners[runID] = cancel + s.piRunnerWg.Add(1) + s.piRunnerMu.Unlock() + + go func() { + defer func() { + s.piRunnerMu.Lock() + delete(s.piRunners, runID) + s.piRunnerMu.Unlock() + s.piRunnerWg.Done() + }() + + if err := s.runCloudAgentPiSession(ctx, userID, runID); err != nil && !errors.Is(err, context.Canceled) { + log.Printf("[Agent] session failed run=%s: %v", runID, err) + s.failPiRunner(runID, userID, errors.New(cloudAgentUserFailureMessage(runID, err))) + } + }() +} + +// closeCloudAgentPiRunners cancels and joins every Pi session before the +// service's database and other dependencies are closed. Without the join, a +// test or graceful shutdown can close the repository while a child runtime is +// still persisting its final checkpoint. +func (s *Service) closeCloudAgentPiRunners() { + if s == nil { + return + } + + s.piRunnerMu.Lock() + if !s.piRunnersClosed { + s.piRunnersClosed = true + } + cancels := make([]context.CancelFunc, 0, len(s.piRunners)) + for _, cancel := range s.piRunners { + cancels = append(cancels, cancel) + } + s.piRunnerMu.Unlock() + + for _, cancel := range cancels { + cancel() + } + s.piRunnerWg.Wait() +} + +func (s *Service) startApprovedCloudAgentMediaWaiter(userID, runID, mediaTaskID string) { + if s == nil || runID == "" || mediaTaskID == "" { + return + } + key := runID + ":" + mediaTaskID + ctx, cancel := context.WithCancel(context.Background()) + + s.approvedMediaMu.Lock() + if s.approvedMediaClosed { + s.approvedMediaMu.Unlock() + cancel() + return + } + if s.approvedMediaWaiters == nil { + s.approvedMediaWaiters = make(map[string]context.CancelFunc) + } + if _, exists := s.approvedMediaWaiters[key]; exists { + s.approvedMediaMu.Unlock() + cancel() + return + } + s.approvedMediaWaiters[key] = cancel + s.approvedMediaWg.Add(1) + s.approvedMediaMu.Unlock() + + go func() { + defer func() { + cancel() + s.approvedMediaMu.Lock() + delete(s.approvedMediaWaiters, key) + s.approvedMediaMu.Unlock() + s.approvedMediaWg.Done() + }() + + if err := s.finishApprovedCloudAgentMedia(ctx, userID, runID, mediaTaskID); err != nil && !errors.Is(err, context.Canceled) { + log.Printf("[Agent] approved media resume failed run=%s: %v", runID, err) + s.failPiRunner(runID, userID, errors.New(cloudAgentUserFailureMessage(runID, err))) + } + }() +} + +func (s *Service) closeApprovedCloudAgentMediaWaiters() { + if s == nil { + return + } + s.approvedMediaMu.Lock() + s.approvedMediaClosed = true + cancels := make([]context.CancelFunc, 0, len(s.approvedMediaWaiters)) + for _, cancel := range s.approvedMediaWaiters { + cancels = append(cancels, cancel) + } + s.approvedMediaMu.Unlock() + + for _, cancel := range cancels { + cancel() + } + s.approvedMediaWg.Wait() +} + +func (s *Service) failPiRunner(runID, userID string, cause error) { + if s == nil || s.repo == nil { + return + } + for attempt := 0; attempt < 4; attempt++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return + } + err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + if cloudAgentRunTerminal(current.Status) { + return nil + } + current.Status = "failed" + current.FailureMessage = cause.Error() + state, decodeErr := cloudAgentDecode(current) + if decodeErr == nil { + state.LastError = cause.Error() + state.event(runID, "run_failed", map[string]any{"error": cause.Error()}) + return cloudAgentSave(current, &state) + } + return nil + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return + } + } +} + +func (s *Service) stopCloudAgentPi(runID string) { + s.piRunnerMu.Lock() + cancel := s.piRunners[runID] + s.piRunnerMu.Unlock() + if cancel != nil { + cancel() + } +} + +func (s *Service) recoverCloudAgentPiRunners() { + if s == nil || s.repo == nil { + return + } + + cursor := "" + for { + runs, err := s.repo.ActiveCloudAgentsAfter(cursor, 50) + if err != nil { + log.Printf("[Agent] recovery scan failed: %v", err) + return + } + + for _, run := range runs { + cursor = run.ID + // 跳过等待状态 + if run.Status == "waiting_approval" || run.Status == "waiting_user" { + continue + } + // 批准后的媒体任务仍在生成:由 finishApprovedCloudAgentMedia 等结果回写后再恢复运行。 + // 这时提前启动运行时,会在媒体任务未结束时再入队模型步骤,形成两个活动任务。 + if s.cloudAgentAwaitingMedia(run.UserID, run.ID) { + continue + } + s.startCloudAgentPi(run.UserID, run.ID) + } + + if len(runs) < 50 { + return + } + } +} + +// cloudAgentAwaitingMedia reports whether the run is waiting on an approved +// media task. The runtime must not start (and schedule a model step) until that +// task has been written back; one run owns at most one active task. +func (s *Service) cloudAgentAwaitingMedia(userID, runID string) bool { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return false + } + state, err := cloudAgentDecode(run) + return err == nil && state.MediaTaskID != "" +} + +// runCloudAgentPiSession 核心会话管理 +func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID string) error { + // 1. 加载运行状态 + task, state, err := s.cloudAgentTask(userID, runID) + if err != nil { + return fmt.Errorf("load task: %w", err) + } + + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return fmt.Errorf("load run: %w", err) + } + + if cloudAgentRunTerminal(run.Status) { + return nil + } + + runtimeState, err := cloudAgentDecode(run) + if err != nil { + return fmt.Errorf("decode state: %w", err) + } + + // 2. 解析输入 + input := map[string]any{} + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + return fmt.Errorf("parse input: %w", err) + } + + canonical := stateCanonicalFromInput(input) + if len(canonical.Messages) == 0 { + return fmt.Errorf("no initial messages") + } + + // 3. 确定模型 + modelID := firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model) + if modelID == "" { + return fmt.Errorf("no model specified") + } + + // 4. 准备会话文件 + sessionJSONL, _ := input["piSessionJSONL"].(string) + if saved, sessionErr := s.repo.CloudAgentPiSession(userID, runID); sessionErr == nil && saved != nil && saved.SessionJSONL != "" { + sessionJSONL = saved.SessionJSONL + } else if sessionErr != nil && !errors.Is(sessionErr, gorm.ErrRecordNotFound) { + return fmt.Errorf("load session: %w", sessionErr) + } + + sessionDir := filepath.Join(s.dataDir, "pi-sessions") + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + return fmt.Errorf("create session dir: %w", err) + } + sessionFile := filepath.Join(sessionDir, runID+".jsonl") + + // 5. 构建完整的 Pi 请求(核心重构) + request, err := s.buildEnhancedPiRequest(ctx, EnhancedPiRequestParams{ + UserID: userID, + RunID: runID, + CanvasID: state.Request.CanvasID, + FocusNodeIDs: state.Request.FocusNodeIDs, + Prompt: state.Request.Prompt, + SystemPrompt: canonical.SystemPrompt, + ModelID: modelID, + RuntimeState: &runtimeState, + Canonical: &canonical, + SessionFile: sessionFile, + SessionJSONL: sessionJSONL, + SessionDir: sessionDir, + }) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + // 6. 如果是恢复会话,使用恢复提示词 + if len(sessionJSONL) > 0 { + request.Prompt = firstNonEmpty(runtimeState.PiResumePrompt, state.Request.Prompt) + } + + // 7. 运行 Pi 会话 + err = runCloudAgentPi(ctx, request, cloudAgentPiBridge{ + Model: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { + return s.cloudAgentPiModel(callCtx, userID, runID, payload) + }, + Tool: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { + return s.cloudAgentPiTool(callCtx, userID, runID, payload) + }, + Event: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { + return s.cloudAgentPiEvent(callCtx, userID, runID, payload) + }, + }) + if err != nil { + return err + } + + // 8. 清理恢复提示词 + if runtimeState.PiResumePrompt != "" { + if err := s.saveCloudAgentPiResumePrompt(userID, runID, ""); err != nil { + return err + } + } + + // 9. 完成运行 + 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) { + log.Printf("[Agent] building enhanced request for canvas=%s", params.CanvasID) + + // 1. 构建工具列表(包括画布工具) + tools := s.buildCompletePiTools(params.Canonical.Tools) + + // 2. 构建 Skills 配置 + skillPaths := s.buildSkillPaths(params.RuntimeState.Skills) + enabledSkills := s.buildSkillManifests(params.RuntimeState.Skills) + + log.Printf("[Agent] enabled skills: %d", 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) + } + + log.Printf("[Agent] canvas intelligence: nodes=%d, focus=%d, relationships=%d, clusters=%d", + enhancedCanvas.Snapshot.TotalNodes, + len(enhancedCanvas.Snapshot.FocusNodes), + len(enhancedCanvas.Snapshot.Relationships), + 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, + } + + log.Printf("[Agent] request built successfully") + 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++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return err + } + + // 检查终止状态 + if cloudAgentRunTerminal(run.Status) || run.Status == "waiting_approval" || run.Status == "waiting_user" { + return nil + } + + state, err := cloudAgentDecode(run) + if err != nil { + return err + } + + // 检查是否有未完成的任务 + if state.ActiveTaskID != "" || state.MediaTaskID != "" || state.Approval != nil { + return nil + } + + // 验证:必须有真实的助手响应(防止伪装完成) + if state.PiAssistantResponses == 0 { + return fmt.Errorf("no assistant response, refusing to mark as completed") + } + + // 原子更新状态 + err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + if cloudAgentRunTerminal(current.Status) || current.Status == "waiting_approval" || current.Status == "waiting_user" { + return nil + } + + current.Status = "completed" + state.event(runID, "run_completed", map[string]any{ + "text": "Agent 已完成", + "timestamp": time.Now().Unix(), + }) + + return cloudAgentSave(current, &state) + }) + + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + + 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 重试也不会变好,直接失败。 + var lastErr error + 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) + if err == nil { + return result, nil + } + lastErr = err + if !retryable { + return nil, err + } + } + return nil, lastErr +} + +// cloudAgentModelStepRetries 是单步模型调用首次失败后的最大重试次数。 +const cloudAgentModelStepRetries = 3 + +// runCloudAgentModelStep 调度并等待一次模型步骤;第二个返回值表示失败是否值得重试。 +func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID string, messages []map[string]any, thinkingLevel string) (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) + canonical.PromptCacheKey = state.Canonical.PromptCacheKey + if len(canonical.Messages) == 0 { + return nil, false, fmt.Errorf("no canonical messages") + } + state.Canonical = canonical + 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 { + retryable := ctx.Err() == nil && s.cloudAgentModelTaskRetryable(taskID) + if retryable { + // 释放失败的步骤,下一次重试才能重新入队。 + if releaseErr := s.finishCloudAgentPiModelStep(userID, runID, taskID, "", ""); releaseErr != nil { + return nil, false, releaseErr + } + } + 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") + } + 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":"工具没有返回结果"}` + } + return map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)}, 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_helpers.go b/backend/internal/app/cloud_agent_pi_helpers.go new file mode 100644 index 000000000..d6e1ae569 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_helpers.go @@ -0,0 +1,286 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + + "infinite-canvas/backend/internal/model" +) + +// Skills Configuration Builders + +// buildSkillPaths 构建 Skills 路径列表 +func (s *Service) buildSkillPaths(skills []cloudAgentSkill) []string { + if len(skills) == 0 { + return []string{} + } + + paths := make([]string, 0, len(skills)) + for _, skill := range skills { + paths = append(paths, cloudAgentSkillPaths(skill)...) + } + return paths +} + +// buildSkillManifests 构建 Skills 清单(传递给 Pi) +func (s *Service) buildSkillManifests(skills []cloudAgentSkill) []map[string]any { + if len(skills) == 0 { + return []map[string]any{} + } + + manifests := make([]map[string]any, 0, len(skills)) + for _, skill := range skills { + manifest := map[string]any{ + "name": skill.Name, + "enabled": true, + "description": skill.Description, + "version": skill.Version, + "hash": skill.Hash, + "instruction": skill.Instruction, + "files": skill.Files, + } + + manifests = append(manifests, manifest) + } + + return manifests +} + +// buildCompactionStrategy 构建压缩策略 +func (s *Service) buildCompactionStrategy(userID, canvasID string) map[string]any { + return map[string]any{ + "enabled": true, + "strategy": "balanced", // balanced/aggressive/conservative + "reserveTokens": 2048, + "prioritizeRecent": true, + "keepSystemPrompt": true, + "keepToolResults": true, + } +} + +// Utility Functions + +// hashContentSHA256 使用 SHA256 计算内容哈希 +func hashContentSHA256(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} + +// truncateString 截断字符串 +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// mergeMetadata 合并元数据 +func mergeMetadata(base, override map[string]interface{}) map[string]interface{} { + if base == nil { + base = make(map[string]interface{}) + } + if override == nil { + return base + } + + result := make(map[string]interface{}) + for k, v := range base { + result[k] = v + } + for k, v := range override { + result[k] = v + } + + return result +} + +// extractNodeIDs 从节点列表提取 ID +func extractNodeIDs(nodes []model.CanvasNode) []string { + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + ids = append(ids, node.ID) + } + return ids +} + +// filterNodesByType 按类型过滤节点 +func filterNodesByType(nodes []model.CanvasNode, nodeType string) []model.CanvasNode { + filtered := make([]model.CanvasNode, 0) + for _, node := range nodes { + if node.Type == nodeType { + filtered = append(filtered, node) + } + } + return filtered +} + +// findNodeByID 通过 ID 查找节点 +func findNodeByID(nodes []model.CanvasNode, nodeID string) *model.CanvasNode { + for _, node := range nodes { + if node.ID == nodeID { + return &node + } + } + return nil +} + +// Validation Functions + +// validateNodeType 验证节点类型 +func validateNodeType(nodeType string) bool { + validTypes := map[string]bool{ + "text": true, + "markdown": true, + "code": true, + "image": true, + "video": true, + "audio": true, + "link": true, + "diagram": true, + "mindmap": true, + "flowchart": true, + "task": true, + "note": true, + } + return validTypes[nodeType] +} + +// validateRelationshipType 验证关系类型 +func validateRelationshipType(relType string) bool { + validTypes := map[string]bool{ + "parent": true, + "reference": true, + "dependency": true, + "flow": true, + "semantic": true, + "custom": true, + } + return validTypes[relType] +} + +// Format Functions + +// formatBytes 格式化字节数 +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// formatDuration 格式化时长 +func formatDuration(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + if ms < 60000 { + return fmt.Sprintf("%.1fs", float64(ms)/1000) + } + return fmt.Sprintf("%.1fm", float64(ms)/60000) +} + +// Debug and Logging Helpers + +// logPiAgentStep 记录 Pi Agent 步骤 +func logPiAgentStep(runID, step, message string) { + log.Printf("[Agent Step] run=%s step=%s: %s", runID, step, message) +} + +// logPiAgentError 记录 Pi Agent 错误 +func logPiAgentError(runID string, err error) { + log.Printf("[Agent Error] run=%s: %v", runID, err) +} + +// logPiAgentMetric 记录 Pi Agent 指标 +func logPiAgentMetric(runID, metric string, value interface{}) { + log.Printf("[Agent Metric] run=%s %s=%v", runID, metric, value) +} + +// Type Conversions + +// toStringSlice 转换为字符串切片 +func toStringSlice(input interface{}) []string { + if input == nil { + return []string{} + } + + switch v := input.(type) { + case []string: + return v + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + default: + return []string{} + } +} + +// toMapStringAny 转换为 map[string]any +func toMapStringAny(input interface{}) map[string]any { + if input == nil { + return make(map[string]any) + } + + if m, ok := input.(map[string]any); ok { + return m + } + + if m, ok := input.(map[string]interface{}); ok { + return m + } + + return make(map[string]any) +} + +// Safe Accessors + +// safeGetString 安全获取字符串 +func safeGetString(m map[string]any, key string, defaultValue string) string { + if v, ok := m[key].(string); ok { + return v + } + return defaultValue +} + +// safeGetInt 安全获取整数 +func safeGetInt(m map[string]any, key string, defaultValue int) int { + if v, ok := m[key].(int); ok { + return v + } + if v, ok := m[key].(float64); ok { + return int(v) + } + return defaultValue +} + +// safeGetBool 安全获取布尔值 +func safeGetBool(m map[string]any, key string, defaultValue bool) bool { + if v, ok := m[key].(bool); ok { + return v + } + return defaultValue +} + +// safeGetFloat 安全获取浮点数 +func safeGetFloat(m map[string]any, key string, defaultValue float64) float64 { + if v, ok := m[key].(float64); ok { + return v + } + if v, ok := m[key].(int); ok { + return float64(v) + } + return defaultValue +} diff --git a/backend/internal/app/cloud_agent_pi_runner_test.go b/backend/internal/app/cloud_agent_pi_runner_test.go new file mode 100644 index 000000000..c0f16c408 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_runner_test.go @@ -0,0 +1,18 @@ +package app + +import ( + "context" + "testing" +) + +func TestStartCloudAgentPiDoesNotRestartAnActiveRunner(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Service{piRunners: map[string]context.CancelFunc{"run-a": cancel}} + s.startCloudAgentPi("user-a", "run-a") + + if err := ctx.Err(); err != nil { + t.Fatalf("duplicate start cancelled the active Pi runner: %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_pi_runtime.go b/backend/internal/app/cloud_agent_pi_runtime.go new file mode 100644 index 000000000..822341e99 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_runtime.go @@ -0,0 +1,49 @@ +package app + +import ( + "context" + + agentruntime "infinite-canvas/backend/internal/agent/runtime" +) + +// These aliases keep the app coordinator's request shape stable while the +// process lifecycle and loopback bridge live in internal/agent/runtime. +type cloudAgentPiBridge = agentruntime.Bridge + +type cloudAgentPiProcessRequest struct { + BridgeURL string `json:"bridgeURL"` + BridgeToken string `json:"bridgeToken"` + SessionFile string `json:"sessionFile,omitempty"` + SessionJSONL string `json:"sessionJSONL,omitempty"` + SessionDir string `json:"sessionDir,omitempty"` + SessionId string `json:"sessionId,omitempty"` + UserId string `json:"userId,omitempty"` + CanvasId string `json:"canvasId,omitempty"` + RunId string `json:"runId,omitempty"` + Cwd string `json:"cwd"` + AgentDir string `json:"agentDir"` + Prompt string `json:"prompt"` + SystemPrompt string `json:"systemPrompt"` + SkillPaths []string `json:"skillPaths,omitempty"` + EnabledSkills []map[string]any `json:"enabledSkills,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Memory map[string]any `json:"memory,omitempty"` + Canvas map[string]any `json:"canvas,omitempty"` + Features map[string]any `json:"features,omitempty"` + Tools []map[string]any `json:"tools"` + Compaction map[string]any `json:"compaction,omitempty"` + Permissions map[string]any `json:"permissions,omitempty"` + Model map[string]any `json:"model"` +} + +func runCloudAgentPi(ctx context.Context, request cloudAgentPiProcessRequest, bridge cloudAgentPiBridge) error { + return agentruntime.Run(ctx, agentruntime.ProcessRequest{ + BridgeURL: request.BridgeURL, BridgeToken: request.BridgeToken, + SessionFile: request.SessionFile, SessionJSONL: request.SessionJSONL, SessionDir: request.SessionDir, + SessionID: request.SessionId, UserID: request.UserId, CanvasID: request.CanvasId, RunID: request.RunId, + CWD: request.Cwd, AgentDir: request.AgentDir, Prompt: request.Prompt, SystemPrompt: request.SystemPrompt, + SkillPaths: request.SkillPaths, EnabledSkills: request.EnabledSkills, Profile: request.Profile, + Memory: request.Memory, Canvas: request.Canvas, Features: request.Features, Tools: request.Tools, + Compaction: request.Compaction, Permissions: request.Permissions, Model: request.Model, + }, agentruntime.Bridge(bridge)) +} diff --git a/backend/internal/app/cloud_agent_pi_standard_tools_test.go b/backend/internal/app/cloud_agent_pi_standard_tools_test.go new file mode 100644 index 000000000..633e584a4 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_standard_tools_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestPiStandardToolsFailWhenExecutorIsUnavailable(t *testing.T) { + service := &Service{} + tools := []string{ + "read_file", "write_file", "edit_file", "delete_file", + "list_directory", "search_files", "glob_files", + "bash", "shell_command", "web_search", "fetch_url", + } + + for _, tool := range tools { + t.Run(tool, func(t *testing.T) { + payload := map[string]json.RawMessage{"arguments": json.RawMessage(`{"command":"pwd"}`)} + _, err := service.routeStandardTool(context.Background(), "user", "run", tool, payload) + if err == nil { + t.Fatal("expected unavailable tool to return an error") + } + if strings.Contains(strings.ToLower(err.Error()), "success") { + t.Fatalf("unavailable tool reported success: %v", err) + } + }) + } +} + +func TestPiStandardToolRejectsMalformedArguments(t *testing.T) { + service := &Service{} + payload := map[string]json.RawMessage{"path": json.RawMessage(`{"unterminated"`)} + _, err := service.routeStandardTool(context.Background(), "user", "run", "read_file", payload) + if err == nil || !strings.Contains(err.Error(), `parameter "path"`) { + t.Fatalf("expected parameter decode error, got %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_profile_test.go b/backend/internal/app/cloud_agent_profile_test.go index ad2cb9754..9f167c5ff 100644 --- a/backend/internal/app/cloud_agent_profile_test.go +++ b/backend/internal/app/cloud_agent_profile_test.go @@ -219,6 +219,9 @@ func TestCloudAgentContinuationReadsLatestProfileWithoutChangingParent(t *testin if err := db.Model(&model.Task{}).Where("id = ?", parent.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": `{"text":"已完成"}`}).Error; err != nil { t.Fatal(err) } + if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", parent.ID).Update("status", "completed").Error; err != nil { + t.Fatal(err) + } secondProfile := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "第二版专属口吻", Revision: firstProfile.Layers[0].Revision}) childRequest := agentTestRequest() childRequest.CanvasID = canvasID diff --git a/backend/internal/app/cloud_agent_reliability_test.go b/backend/internal/app/cloud_agent_reliability_test.go index dfbd34a19..687c009b0 100644 --- a/backend/internal/app/cloud_agent_reliability_test.go +++ b/backend/internal/app/cloud_agent_reliability_test.go @@ -309,6 +309,9 @@ func TestCloudAgentReliabilityFailedContinuation(t *testing.T) { if err := db.Model(&model.Task{}).Where("id = ?", root.ID).Updates(map[string]any{"status": model.TaskStatusFailed, "error": "mock failure"}).Error; err != nil { t.Fatal(err) } + if err := s.advanceCloudAgentByID("user", root.ID); err != nil { + t.Fatal(err) + } req := agentTestRequest() req.Prompt = "继续刚才的任务" req.IdempotencyKey = "audit-continuation-key" diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 1a3ae30c6..8517e9026 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -166,9 +166,16 @@ type cloudAgentRuntime struct { ConfirmationFingerprints []string `json:"confirmationFingerprints,omitempty"` PendingConfirmationFingerprint string `json:"pendingConfirmationFingerprint,omitempty"` PendingInterjections []cloudAgentInterjection `json:"pendingInterjections,omitempty"` + PiResumePrompt string `json:"piResumePrompt,omitempty"` TransientReferences map[string]cloudAgentTransientReference `json:"transientReferences,omitempty"` InterjectionIDs []string `json:"interjectionIds,omitempty"` Events []CloudAgentEvent `json:"events"` + // PiAssistantResponses counts successful assistant message_end events from + // the Pi runtime. Completion must not be inferred from a clean Node exit: + // a provider/session error can otherwise be reported as a successful run. + PiAssistantResponses int `json:"piAssistantResponses,omitempty"` + IsGenerating bool `json:"isGenerating,omitempty"` + LastError string `json:"lastError,omitempty"` // EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。 EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"` // StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。 @@ -260,7 +267,17 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent if err != nil { return err } - state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, ConfirmationRounds: initial.ConfirmationRounds, ConfirmationFingerprints: append([]string(nil), initial.ConfirmationFingerprints...), PendingConfirmationFingerprint: "", Events: []CloudAgentEvent{}, StepLimits: limits} + carrier := task.Status == model.TaskStatusTextReplay + // The Pi root is a non-billable control-plane carrier, but it is still the + // durable task identity for this run. Keep it in TaskIDs even though it is + // not an active model task; validation and recovery use TaskIDs as the run's + // immutable task-history anchor. Leaving this nil makes every new Pi run + // fail its first checkpoint with "Agent runtime task history is invalid". + state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: "", TaskIDs: []string{task.ID}, Step: 0, Decisions: map[string]string{}, Plan: initial.Plan, ConfirmationRounds: initial.ConfirmationRounds, ConfirmationFingerprints: append([]string(nil), initial.ConfirmationFingerprints...), PendingConfirmationFingerprint: "", Events: []CloudAgentEvent{}, StepLimits: limits} + if !carrier { + state.ActiveTaskID = task.ID + state.Step = 1 + } if len(initial.Skills) > 0 { // skillIds makes the enablement auditable: usage telemetry can attribute a // run to the skills it actually loaded instead of only counting the total. @@ -271,6 +288,16 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "skillIds": skillIDs, "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))}) } pressure := s.cloudAgentContextPressure(input.Requests.Canonical, initial.Request.Prompt, initial.Request) + if carrier { + // The carrier is deliberately not a model call. Pi will create the first + // governed cloud_agent_step through the model bridge below. + state.ContextWindowKnown = pressure.ModelLimitConfigured + run := &model.CloudAgentExecution{ID: task.ID, UserID: task.UserID, Status: "running", Revision: 1, CreatedAt: task.CreatedAt, UpdatedAt: time.Now()} + if err := cloudAgentSave(run, &state); err != nil { + return err + } + return s.repo.EnsureCloudAgent(run) + } // 第一步的模型调用就是根任务本身(不经过 enqueueCloudAgentTask):在这里登记任务 id // 与本次请求的本地计价,它回来时才能与上游实测配成锚点。根任务的操作名是 // cloud_agent,但它就是第一步的模型调用,按"步骤"口径登记,否则回来配锚点时会被 @@ -1391,26 +1418,11 @@ func cloudAgentSafeUserMessage(message string) bool { // 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 { + if task == nil || strings.TrimSpace(task.Error) == "" { return "媒体任务未成功" } - detail := strings.TrimSpace(task.Error) - if detail == "" || !utf8.ValidString(detail) || strings.ContainsAny(detail, "\r\n\x00") { - return "媒体任务未成功" - } - lower := strings.ToLower(detail) - for _, marker := range []string{ - "http://", "https://", "ftp://", "authorization", "cookie", "secret", "token", "api_key", "apikey", "x-api-key", - } { - if strings.Contains(lower, marker) { - return "媒体任务未成功" - } - } - runes := []rune(detail) - if len(runes) > 240 { - detail = string(runes[:240]) + "…" - } - return detail + // 与画布节点展示同一套分类:网络/审核/存储/HTTP 状态归类,可读的供应商原因原样保留。 + return userFacingTaskError(task.Error) } func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool { @@ -2441,9 +2453,10 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { return creationConflict("审批不存在或已过期") } + // 只在记录审批决定的这次写入期间持锁:随后的工具执行(advanceCloudAgentTool) + // 会自己获取 storageMu,持锁到函数返回会造成自锁。 s.storageMu.Lock() - defer s.storageMu.Unlock() - return s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + 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 @@ -2492,8 +2505,92 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea state.event(id, "approval_decided", payload) return cloudAgentSave(current, &state) }) + s.storageMu.Unlock() + 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. + latest, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err = cloudAgentDecode(latest) + if err != nil { + return err + } + if err = s.advanceCloudAgentTool(latest, &state); err != nil { + return err + } + if state.MediaTaskID == "" { + return s.resumeCloudAgentAfterApproval(userID, id, &state) + } + // 媒体生成可能要几分钟:审批请求立即返回,等待与回写在后台完成后再恢复运行。 + mediaTaskID := state.MediaTaskID + 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.startCloudAgentPi(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. @@ -2522,7 +2619,17 @@ func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) { return nil } - if run.Status != "failed" { + // 取消是用户操作,不能因为运行时正在并发写事件就失败:冲突时重读最新 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) @@ -2544,9 +2651,16 @@ func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error } 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 { diff --git a/backend/internal/app/cloud_agent_runtime_e2e_test.go b/backend/internal/app/cloud_agent_runtime_e2e_test.go new file mode 100644 index 000000000..5fbbbfff3 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_e2e_test.go @@ -0,0 +1,276 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "sync" + "testing" + "time" + + agentruntime "infinite-canvas/backend/internal/agent/runtime" + "infinite-canvas/backend/internal/model" +) + +// 端到端:生产入口 CreateCloudAgentRun → 真实 Node 运行时 → /model 桥接 → 任务 worker → +// 模拟上游(第一步返回工具调用,第二步返回正文)→ /tool 桥接 → 运行完成。 +// 任何一层格式或并发问题都会让这里失败,而不是等用户在界面上一个个撞到。 +func TestCloudAgentRuntimeCompletesToolRoundTrip(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + if _, err := agentRuntimeDirForTest(); err != nil { + t.Skip(err.Error()) + } + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + t.Setenv("REDIS_URL", "") + + var mu sync.Mutex + var bodies []map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode upstream body: %v", err) + } + mu.Lock() + bodies = append(bodies, body) + call := len(bodies) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if call == 1 { + tool := firstUpstreamToolName(body) + if tool == "" { + t.Errorf("first model step exposed no tools: %v", body["tools"]) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"没有工具"}}]}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","type":"function","function":{"name":"` + tool + `","arguments":"{}"}}]}}]}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"画布已检查完毕"}}]}`)) + })) + defer upstream.Close() + + s, db, _, _ := creationTestService(t) + s = New(s.repo, s.dataDir) + t.Cleanup(func() { _ = s.Close() }) + if err := db.Model(&model.ModelChannel{}).Where("id = ?", "channel").Updates(map[string]any{"base_url": upstream.URL, "api_key": "test-only"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { + t.Fatal(err) + } + + stop := make(chan struct{}) + defer close(stop) + go func() { + for { + select { + case <-stop: + return + default: + } + _ = s.ProcessNextTask() + time.Sleep(20 * time.Millisecond) + } + }() + + run, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(60 * time.Second) + var execution *model.CloudAgentExecution + for time.Now().Before(deadline) { + execution, err = s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + if cloudAgentRunTerminal(execution.Status) || execution.Status == "waiting_approval" || execution.Status == "waiting_user" { + break + } + time.Sleep(100 * time.Millisecond) + } + if execution.Status != "completed" { + t.Fatalf("run status=%s failure=%q", execution.Status, execution.FailureMessage) + } + + mu.Lock() + calls := len(bodies) + second := map[string]any{} + if calls >= 2 { + second = bodies[1] + } + mu.Unlock() + if calls != 2 { + t.Fatalf("expected 2 upstream model calls, got %d", calls) + } + encoded, _ := json.Marshal(second["messages"]) + if !strings.Contains(string(encoded), `"tool_call_id":"call-1"`) { + t.Fatalf("second model step lost the tool result: %s", encoded) + } + if strings.Contains(string(encoded), "unknown tool") || strings.Contains(string(encoded), `\"error\"`) { + t.Fatalf("tool call did not succeed: %s", encoded) + } + if strings.Contains(string(encoded), "canvas_node_create") { + t.Fatalf("model saw tools from a second registry: %s", encoded) + } + if strings.Contains(strings.ToLower(execution.FailureMessage), "pi") { + t.Fatalf("failure message exposes runtime name: %q", execution.FailureMessage) + } + + full, err := s.CloudAgentRun("user", run.ID) + if err != nil { + t.Fatal(err) + } + rawEvents, _ := json.Marshal(full.Events) + if !strings.Contains(string(rawEvents), "画布已检查完毕") { + t.Fatalf("final reply missing from run events: %s", rawEvents) + } + if session, err := s.repo.CloudAgentPiSession("user", run.ID); err != nil || session.SessionJSONL == "" { + t.Fatalf("session snapshot not persisted: %v", err) + } +} + +func firstUpstreamToolName(body map[string]any) string { + tools, _ := body["tools"].([]any) + for _, value := range tools { + tool, _ := value.(map[string]any) + fn, _ := tool["function"].(map[string]any) + name := stringField(fn, "name") + if strings.HasPrefix(name, "canvas_") && name != "canvas_apply_ops" { + return name + } + } + for _, value := range tools { + tool, _ := value.(map[string]any) + fn, _ := tool["function"].(map[string]any) + if name := stringField(fn, "name"); name != "" { + return name + } + } + return "" +} + +func agentRuntimeDirForTest() (string, error) { + return agentruntime.RuntimeDir() +} + +// 端到端:审批模式下写画布 → 等待审批 → 用户批准 → Go 执行一次写入 → 运行时恢复 → 完成。 +func TestCloudAgentRuntimeApprovalWriteRoundTrip(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + if _, err := agentRuntimeDirForTest(); err != nil { + t.Skip(err.Error()) + } + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + t.Setenv("REDIS_URL", "") + + canvas := model.CanvasProject{ID: "agent-canvas", UserID: "user", Title: "test", PayloadJSON: `{"nodes":[]}`} + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + writeArgs, _ := json.Marshal(map[string]any{ + "snapshotHash": cloudAgentCanvasHash(doc), + "ops": []map[string]any{{"type": "add_node", "id": "agent-note", "nodeType": "text", "title": "Agent note", "content": "由 Agent 写入", "x": 24, "y": 48}}, + }) + writeCall, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{ + "content": "", "tool_calls": []any{map[string]any{"id": "write-1", "type": "function", "function": map[string]any{"name": "canvas_apply_ops", "arguments": string(writeArgs)}}}, + }}}}) + + var mu sync.Mutex + calls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + mu.Lock() + calls++ + call := calls + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if call == 1 { + _, _ = w.Write(writeCall) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"已写入画布"}}]}`)) + })) + defer upstream.Close() + + s, db, _, _ := creationTestService(t) + s = New(s.repo, s.dataDir) + t.Cleanup(func() { _ = s.Close() }) + if err := db.Model(&model.ModelChannel{}).Where("id = ?", "channel").Updates(map[string]any{"base_url": upstream.URL, "api_key": "test-only"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&canvas).Error; err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + defer close(stop) + go func() { + for { + select { + case <-stop: + return + default: + } + _ = s.ProcessNextTask() + time.Sleep(20 * time.Millisecond) + } + }() + + req := agentTestRequest() + req.PermissionMode = "request_approval" + run, err := s.CreateCloudAgentRun("user", req, "") + if err != nil { + t.Fatal(err) + } + waiting := waitCloudAgentStatus(t, s, run.ID, "waiting_approval") + if waiting.Approval == nil { + t.Fatalf("approval missing: status=%s", waiting.Status) + } + if err := s.DecideCloudAgentApproval("user", run.ID, waiting.Approval.ID, "approve", "确认写入"); err != nil { + t.Fatal(err) + } + final := waitCloudAgentStatus(t, s, run.ID, "completed") + var stored model.CanvasProject + if err := db.First(&stored, "id = ? AND user_id = ?", canvas.ID, "user").Error; err != nil { + t.Fatal(err) + } + if strings.Count(stored.PayloadJSON, "由 Agent 写入") != 1 { + t.Fatalf("approved write must be applied exactly once: %s", stored.PayloadJSON) + } + raw, _ := json.Marshal(final.Events) + if !strings.Contains(string(raw), "已写入画布") { + t.Fatalf("final reply missing after approval: %s", raw) + } +} + +func waitCloudAgentStatus(t *testing.T, s *Service, runID, want string) *CloudAgentRun { + t.Helper() + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + execution, err := s.repo.CloudAgent("user", runID) + if err != nil { + t.Fatal(err) + } + if execution.Status == want { + run, err := s.CloudAgentRun("user", runID) + if err != nil { + t.Fatal(err) + } + return run + } + if cloudAgentRunTerminal(execution.Status) { + t.Fatalf("run ended as %s (want %s): %q", execution.Status, want, execution.FailureMessage) + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", want) + return nil +} diff --git a/backend/internal/app/cloud_agent_runtime_live_regressions_test.go b/backend/internal/app/cloud_agent_runtime_live_regressions_test.go new file mode 100644 index 000000000..ce80e6661 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_live_regressions_test.go @@ -0,0 +1,245 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +// 这组测试逐条复现线上日志里的失败,使用真实 Node 运行时 + 流式 SSE 模拟上游: +// 1. 首步调用 recall_lessons,工具结果被当成 "null" 交回模型 → 上游空回复。 +// 2. 工具表里 12 个 canvas_* 重名 → xAI 返回 400。 +// 3. 上游偶发 500 → 必须自动重试,而不是整轮失败。 +// 4. 推理增量逐 token 流式到达时空格被吃掉。 + +type liveUpstreamCall struct { + body map[string]any +} + +type liveUpstream struct { + mu sync.Mutex + calls []liveUpstreamCall + handler func(call int, body map[string]any, w http.ResponseWriter) +} + +func (u *liveUpstream) record(body map[string]any) int { + u.mu.Lock() + defer u.mu.Unlock() + u.calls = append(u.calls, liveUpstreamCall{body: body}) + return len(u.calls) +} + +func (u *liveUpstream) snapshot() []liveUpstreamCall { + u.mu.Lock() + defer u.mu.Unlock() + return append([]liveUpstreamCall(nil), u.calls...) +} + +func writeSSE(w http.ResponseWriter, chunks ...map[string]any) { + w.Header().Set("Content-Type", "text/event-stream") + for _, chunk := range chunks { + raw, _ := json.Marshal(chunk) + _, _ = fmt.Fprintf(w, "data: %s\n\n", raw) + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") +} + +func sseDelta(delta map[string]any) map[string]any { + return map[string]any{"choices": []any{map[string]any{"index": 0, "delta": delta}}} +} + +func startLiveAgentService(t *testing.T, upstream *liveUpstream, permissionMode string) (*Service, string) { + t.Helper() + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + if _, err := agentRuntimeDirForTest(); err != nil { + t.Skip(err.Error()) + } + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + t.Setenv("REDIS_URL", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode upstream body: %v", err) + } + upstream.handler(upstream.record(body), body, w) + })) + t.Cleanup(server.Close) + + s, db, _, _ := creationTestService(t) + s = New(s.repo, s.dataDir) + t.Cleanup(func() { _ = s.Close() }) + if err := db.Model(&model.ModelChannel{}).Where("id = ?", "channel").Updates(map[string]any{"base_url": server.URL, "api_key": "test-only"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + go func() { + for { + select { + case <-stop: + return + default: + } + _ = s.ProcessNextTask() + time.Sleep(20 * time.Millisecond) + } + }() + req := agentTestRequest() + if permissionMode != "" { + req.PermissionMode = permissionMode + } + run, err := s.CreateCloudAgentRun("user", req, "") + if err != nil { + t.Fatal(err) + } + return s, run.ID +} + +func waitLiveAgentTerminal(t *testing.T, s *Service, runID string) *model.CloudAgentExecution { + t.Helper() + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + execution, err := s.repo.CloudAgent("user", runID) + if err != nil { + t.Fatal(err) + } + if cloudAgentRunTerminal(execution.Status) { + return execution + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("run did not reach a terminal state") + return nil +} + +func upstreamToolNames(body map[string]any) []string { + names := []string{} + tools, _ := body["tools"].([]any) + for _, value := range tools { + tool, _ := value.(map[string]any) + fn, _ := tool["function"].(map[string]any) + names = append(names, stringField(fn, "name")) + } + return names +} + +func upstreamToolMessages(body map[string]any) []map[string]any { + result := []map[string]any{} + messages, _ := body["messages"].([]any) + for _, value := range messages { + message, _ := value.(map[string]any) + if stringField(message, "role") == "tool" { + result = append(result, message) + } + } + return result +} + +func TestCloudAgentLiveRecallLessonsStreamingRoundTrip(t *testing.T) { + upstream := &liveUpstream{} + upstream.handler = func(call int, body map[string]any, w http.ResponseWriter) { + if call == 1 { + writeSSE(w, + sseDelta(map[string]any{"role": "assistant"}), + sseDelta(map[string]any{"reasoning_content": "The"}), + sseDelta(map[string]any{"reasoning_content": " user"}), + sseDelta(map[string]any{"reasoning_content": " said hello"}), + sseDelta(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "call-recall", "type": "function", "function": map[string]any{"name": "recall_lessons", "arguments": ""}}}}), + sseDelta(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "function": map[string]any{"arguments": `{"topic":"用户称呼偏好"}`}}}}), + ) + return + } + writeSSE(w, sseDelta(map[string]any{"content": "你好,"}), sseDelta(map[string]any{"content": "有什么可以帮你?"})) + } + s, runID := startLiveAgentService(t, upstream, "") + execution := waitLiveAgentTerminal(t, s, runID) + calls := upstream.snapshot() + if execution.Status != "completed" { + t.Fatalf("status=%s failure=%q calls=%d", execution.Status, execution.FailureMessage, len(calls)) + } + if len(calls) != 2 { + t.Fatalf("expected 2 upstream calls, got %d", len(calls)) + } + for i, call := range calls { + seen := map[string]bool{} + for _, name := range upstreamToolNames(call.body) { + if seen[name] { + t.Fatalf("call %d sent duplicate tool %q", i+1, name) + } + seen[name] = true + } + } + results := upstreamToolMessages(calls[1].body) + if len(results) != 1 { + t.Fatalf("second call should carry one tool result: %#v", results) + } + content := strings.TrimSpace(stringField(results[0], "content")) + if content == "" || content == "null" { + t.Fatalf("tool result sent to model is empty: %q", content) + } + full, err := s.CloudAgentRun("user", runID) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(full.Events) + if !strings.Contains(string(raw), "The user said hello") { + t.Fatalf("reasoning lost its spaces: %s", raw) + } + if !strings.Contains(string(raw), "你好,有什么可以帮你?") { + t.Fatalf("final reply missing: %s", raw) + } +} + +func TestCloudAgentLiveRetriesTransientUpstreamFailure(t *testing.T) { + upstream := &liveUpstream{} + upstream.handler = func(call int, body map[string]any, w http.ResponseWriter) { + if call <= 2 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"message":"Upstream gateway error","type":"api_error"}}`)) + return + } + writeSSE(w, sseDelta(map[string]any{"content": "重试后成功"})) + } + s, runID := startLiveAgentService(t, upstream, "") + execution := waitLiveAgentTerminal(t, s, runID) + if execution.Status != "completed" { + t.Fatalf("transient 500 was not retried: status=%s failure=%q", execution.Status, execution.FailureMessage) + } + if calls := len(upstream.snapshot()); calls != 3 { + t.Fatalf("expected 2 failures + 1 success, got %d upstream calls", calls) + } +} + +func TestCloudAgentLiveDoesNotRetryRejectedRequest(t *testing.T) { + upstream := &liveUpstream{} + upstream.handler = func(call int, body map[string]any, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"bad request","type":"invalid_request_error"}}`)) + } + s, runID := startLiveAgentService(t, upstream, "") + execution := waitLiveAgentTerminal(t, s, runID) + if execution.Status != "failed" { + t.Fatalf("expected failure, got %s", execution.Status) + } + if calls := len(upstream.snapshot()); calls != 1 { + t.Fatalf("400 must not be retried, got %d upstream calls", calls) + } + if strings.Contains(strings.ToLower(execution.FailureMessage), "pi ") { + t.Fatalf("failure message exposes runtime name: %q", execution.FailureMessage) + } +} diff --git a/backend/internal/app/cloud_agent_runtime_messages_test.go b/backend/internal/app/cloud_agent_runtime_messages_test.go new file mode 100644 index 000000000..3c3754878 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_messages_test.go @@ -0,0 +1,43 @@ +package app + +import "testing" + +// 运行时会话消息(user/assistant/toolResult + 内容块)必须转换成供应商层能接受的规范请求, +// 否则第一步模型调用就会因“缺少工具选择”等校验失败。 +func TestCanonicalFromRuntimeMessagesPassesProviderValidation(t *testing.T) { + messages := []map[string]any{ + {"role": "user", "content": []interface{}{map[string]interface{}{"type": "text", "text": "帮我整理画布"}}, "timestamp": 1}, + {"role": "assistant", "content": []interface{}{ + map[string]interface{}{"type": "thinking", "thinking": "先看看画布"}, + map[string]interface{}{"type": "text", "text": "我先读取画布。"}, + map[string]interface{}{"type": "toolCall", "id": "call-1", "name": "canvas_snapshot", "arguments": map[string]interface{}{}}, + }}, + {"role": "toolResult", "toolCallId": "call-1", "toolName": "canvas_snapshot", "content": []interface{}{map[string]interface{}{"type": "text", "text": "{\"nodes\":0}"}}, "isError": false}, + } + tools := runtimeToolSchemas([]map[string]any{{"name": "canvas_snapshot", "description": "获取画布快照", "parameters": map[string]any{"type": "object", "properties": map[string]any{}}}}) + canonical := canonicalFromRuntimeMessages(messages, tools, "你是画布助手") + if canonical.ToolChoice != "auto" { + t.Fatalf("tool choice = %v", canonical.ToolChoice) + } + if len(canonical.Messages) != 4 || stringField(canonical.Messages[0], "role") != "system" { + t.Fatalf("unexpected messages: %#v", canonical.Messages) + } + if stringField(canonical.Messages[3], "role") != "tool" || stringField(canonical.Messages[3], "tool_call_id") != "call-1" { + t.Fatalf("tool result not converted: %#v", canonical.Messages[3]) + } + if _, err := expandCanonicalAgentRequest(&canonical, providerConfig{}, false); err != nil { + t.Fatalf("provider validation rejected runtime transcript: %v", err) + } +} + +func TestRuntimeToolCallsDecodeArguments(t *testing.T) { + var call cloudAgentCall + call.ID = "call-1" + call.Function.Name = "canvas_node_read" + call.Function.Arguments = `{"nodeId":"n1"}` + out := runtimeToolCalls([]cloudAgentCall{call}) + args, _ := out[0]["arguments"].(map[string]any) + if out[0]["name"] != "canvas_node_read" || args["nodeId"] != "n1" { + t.Fatalf("unexpected runtime call: %#v", out[0]) + } +} diff --git a/backend/internal/app/cloud_agent_runtime_tool_result_test.go b/backend/internal/app/cloud_agent_runtime_tool_result_test.go new file mode 100644 index 000000000..1f05bfae6 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_tool_result_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +// 线上复现:recall_lessons 经运行时工具桥执行后,交回模型的 tool 消息是 "null", +// 上游拿到空结果后返回空内容,整轮失败。任何工具结果都必须是非空的 JSON 文本。 +func TestCloudAgentRuntimeToolResultIsNeverNull(t *testing.T) { + s, db, _, _ := creationTestService(t) + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { + t.Fatal(err) + } + run, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ name, args string }{ + {"recall_lessons", `{"topic":"用户称呼偏好"}`}, + {"recall_lessons", `{}`}, + {"canvas_list_node_types", `{}`}, + {"canvas_get_state", `{}`}, + } { + var call cloudAgentCall + call.ID = "call-" + tc.name + "-" + tc.args + call.Function.Name = tc.name + call.Function.Arguments = tc.args + result, err := s.executeCloudAgentRuntimeTool(context.Background(), "user", run.ID, call) + if err != nil { + t.Fatalf("%s(%s) bridge error: %v", tc.name, tc.args, err) + } + payload, _ := result.(map[string]any) + content, _ := payload["content"].(string) + if strings.TrimSpace(content) == "" || content == "null" { + t.Fatalf("%s(%s) returned empty tool content: %#v", tc.name, tc.args, result) + } + } +} diff --git a/backend/internal/app/cloud_agent_single_active_task_test.go b/backend/internal/app/cloud_agent_single_active_task_test.go new file mode 100644 index 000000000..e29469ec5 --- /dev/null +++ b/backend/internal/app/cloud_agent_single_active_task_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +// 线上复现:"invalid Agent checkpoint (runtime validation): Agent runtime has multiple active tasks"。 +// 批准媒体生成后,媒体任务还在跑(MediaTaskID 已设置),运行时又请求下一步模型调用; +// 调度模型步骤会同时设置 ActiveTaskID,检查点校验拒绝,整轮失败。 +// 修复后模型步骤必须先等媒体结果回写、释放 MediaTaskID,再入队。 +func TestCloudAgentModelStepWaitsForPendingMediaTask(t *testing.T) { + s, db, a := agentMediaFixture(t) + run, state := agentMediaRun(t, s, a, "auto") + if err := s.advanceCloudAgentTool(run, &state); err != nil { + t.Fatal(err) + } + run, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + state, err = cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + if state.MediaTaskID == "" { + t.Fatalf("fixture did not submit a media task (status=%s failure=%q)", run.Status, run.FailureMessage) + } + mediaTaskID := state.MediaTaskID + + // 模拟媒体任务稍后才结束。 + go func() { + time.Sleep(300 * time.Millisecond) + _ = db.Model(&model.Task{}).Where("id = ?", mediaTaskID).Updates(map[string]any{"status": model.TaskStatusFailed, "error": "HTTP 502 Bad Gateway"}).Error + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []map[string]any{{"role": "user", "content": "继续"}} + done := make(chan error, 1) + go func() { + _, _, stepErr := s.runCloudAgentModelStep(ctx, "user", run.ID, messages, "off") + done <- stepErr + }() + + // 模型步骤在等待期间,检查点里绝不能同时出现两个活动任务。 + deadline := time.Now().Add(8 * time.Second) + scheduled := false + for time.Now().Before(deadline) && !scheduled { + latest, readErr := s.repo.CloudAgent("user", run.ID) + if readErr != nil { + t.Fatal(readErr) + } + current, decodeErr := cloudAgentDecode(latest) + if decodeErr != nil { + t.Fatalf("checkpoint became invalid while waiting for media: %v", decodeErr) + } + if current.ActiveTaskID != "" && current.MediaTaskID != "" { + t.Fatalf("model step scheduled while media task %s still active", current.MediaTaskID) + } + if current.ActiveTaskID != "" { + scheduled = true + if current.MediaTaskID != "" { + t.Fatal("media task not released before model step") + } + } + if latest.Status == "failed" { + t.Fatalf("run failed while waiting for media: %q", latest.FailureMessage) + } + time.Sleep(50 * time.Millisecond) + } + if !scheduled { + t.Fatal("model step was never scheduled after the media task finished") + } + cancel() + <-done +} diff --git a/backend/internal/app/cloud_agent_storyboard.go b/backend/internal/app/cloud_agent_storyboard.go index bbd674896..428810d8a 100644 --- a/backend/internal/app/cloud_agent_storyboard.go +++ b/backend/internal/app/cloud_agent_storyboard.go @@ -184,10 +184,8 @@ func prepareCloudAgentStoryboardCreate(repo *repository.Repository, userID, canv if err != nil { return nil, err } + // 新建节点不依赖其它节点的内容:用户同时删改别的节点不影响这次创建,只要求 ID 未被占用。 beforeHash := cloudAgentCanvasHash(doc) - if beforeHash != args.SnapshotHash { - return nil, creationConflict("画布已变化,本次未写入;请重新读取并重新申请审批") - } for _, node := range creationMaps(doc["nodes"]) { if stringValue(node["id"]) == args.NodeID { return nil, BadAuthRequest("分镜节点ID已存在") @@ -244,8 +242,8 @@ func prepareCloudAgentStoryboardEdit(repo *repository.Repository, userID, canvas return nil, err } beforeHash := cloudAgentCanvasHash(doc) - if beforeHash != args.SnapshotHash { - return nil, creationConflict("画布已变化,本次未写入;请重新读取并重新申请审批") + if !cloudAgentNodeSnapshotMatches(doc, args.NodeID, args.SnapshotHash) { + return nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "这个分镜在你读取之后被修改过,本次未写入;请重新读取分镜后再改") } node, storyboard, rows, err := storyboardNodeFromDocument(doc, args.NodeID) if err != nil { @@ -346,7 +344,7 @@ func applyCloudAgentStoryboardMutation(repo *repository.Repository, userID, canv if len(plan.Preview.Items) > 0 { nodeID = plan.Preview.Items[0].NodeID } - return map[string]any{"canvasId": canvasID, "nodeId": nodeID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "summary": plan.Preview.Description, "preview": plan.Preview}, nil + return map[string]any{"canvasId": canvasID, "nodeId": nodeID, "snapshotHash": cloudAgentNodeHash(plan.Document, nodeID), "summary": plan.Preview.Description, "preview": plan.Preview}, nil } func mapsAsAny(values []map[string]any) []any { @@ -357,7 +355,7 @@ func mapsAsAny(values []map[string]any) []any { return out } -func cloudAgentStoryboardReadResult(view any, nodeID string) (map[string]any, error) { +func cloudAgentStoryboardReadResult(view any, nodeID, nodeHash string) (map[string]any, error) { state, ok := view.(map[string]any) if !ok { return nil, BadAuthRequest("分镜读取结果无效") @@ -383,7 +381,7 @@ func cloudAgentStoryboardReadResult(view any, nodeID string) (map[string]any, er return map[string]any{ "nodeId": nodeID, "title": node["title"], - "snapshotHash": state["snapshotHash"], + "snapshotHash": nodeHash, "storyboard": storyboard, }, nil } diff --git a/backend/internal/app/cloud_agent_storyboard_test.go b/backend/internal/app/cloud_agent_storyboard_test.go index 470043677..c5b3883a4 100644 --- a/backend/internal/app/cloud_agent_storyboard_test.go +++ b/backend/internal/app/cloud_agent_storyboard_test.go @@ -272,7 +272,7 @@ func TestCloudAgentStoryboardRejectsUnsafeOrStaleMutations(t *testing.T) { "snapshotHash": "stale", "nodeId": "storyboard-1", "action": "update", "rowId": rowID, "patch": map[string]any{"dialogue": "不应写入"}, }) - if _, err := prepareCloudAgentStoryboardEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "画布已变化") { + if _, err := prepareCloudAgentStoryboardEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "被修改过") { t.Fatalf("stale snapshot was not rejected: %v", err) } diff --git a/backend/internal/app/cloud_agent_test.go b/backend/internal/app/cloud_agent_test.go index 0f95fc006..44680926f 100644 --- a/backend/internal/app/cloud_agent_test.go +++ b/backend/internal/app/cloud_agent_test.go @@ -17,6 +17,33 @@ func agentTestRequest() CloudAgentRequest { return req } +func TestCloudAgentPiCarrierCheckpointKeepsTaskHistoryAnchor(t *testing.T) { + s, db, _, _ := creationTestService(t) + s.legacyCloudAgentRootTask = false + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { + t.Fatal(err) + } + + run, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatalf("new Pi Agent run must checkpoint successfully: %v", err) + } + execution, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(execution) + if err != nil { + t.Fatal(err) + } + if state.ActiveTaskID != "" { + t.Fatalf("Pi carrier must not be an active model task: %q", state.ActiveTaskID) + } + if len(state.TaskIDs) != 1 || state.TaskIDs[0] != run.ID { + t.Fatalf("Pi carrier lost its durable task-history anchor: %#v", state.TaskIDs) + } +} + func TestCloudAgentRunSurvivesTaskInputCompaction(t *testing.T) { s, db, _, _ := creationTestService(t) if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { @@ -334,6 +361,9 @@ func TestCloudAgentAdmissionAndContinuation(t *testing.T) { if err := db.Model(&model.Task{}).Where("id = ?", run.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": `{"text":"可信回复"}`}).Error; err != nil { t.Fatal(err) } + if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", run.ID).Update("status", "completed").Error; err != nil { + t.Fatal(err) + } continued, err := s.CreateCloudAgentRun("user", next, run.ID) if err != nil { t.Fatal(err) @@ -418,6 +448,8 @@ func TestCloudAgentWorkerPersistsRealResponse(t *testing.T) { defer upstream.Close() s, db, _, _ := creationTestService(t) s = New(s.repo, s.dataDir) + s.disablePiRuntime = true + s.legacyCloudAgentRootTask = true t.Cleanup(func() { _ = s.Close() }) if err := db.Model(&model.ModelChannel{}).Where("id = ?", "channel").Updates(map[string]any{"base_url": upstream.URL, "api_key": "test-only"}).Error; err != nil { t.Fatal(err) diff --git a/backend/internal/app/cloud_agent_tool_repair_test.go b/backend/internal/app/cloud_agent_tool_repair_test.go index 6da1eb163..b8e48d229 100644 --- a/backend/internal/app/cloud_agent_tool_repair_test.go +++ b/backend/internal/app/cloud_agent_tool_repair_test.go @@ -170,8 +170,15 @@ func TestCloudAgentEmptyCanvasRepairAcrossCheckpoints(t *testing.T) { if run.Status == "failed" || len(state.ToolRepairs) != 0 { t.Fatal("successful correction did not resolve retry state") } - last := state.Events[len(state.Events)-1] - if last.Type != "tool_completed" || last.Payload["retry"].(map[string]any)["status"] != "recovered" { + recovered := false + for _, event := range state.Events { + retry, _ := event.Payload["retry"].(map[string]any) + if event.Type == "tool_completed" && retry["status"] == "recovered" { + recovered = true + break + } + } + if !recovered { t.Fatal("missing recovery receipt") } stored, _ := s.repo.CanvasProjectForUser("user", canvas.ID) diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index dbf2e9c3d..e13974e6f 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -553,14 +553,14 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "y": map[string]any{"type": "number"}, }, "snapshotHash", "nodeId", "title", "rows") add("canvas_edit_storyboard", "追加、修改或删除分镜脚本中的单个镜头行。必须先用 canvas_read_storyboard 读取最新 snapshotHash 和真实 rowId;append 不传 rowId,update/remove 必须传。patch 只允许镜头文本与时长,不能修改素材绑定、媒体节点ID、任务状态、资源URL或任意 metadata。", map[string]any{ - "snapshotHash": str("最近一次分镜读取返回的 snapshotHash"), + "snapshotHash": str("最近一次 canvas_read_storyboard 返回的 snapshotHash(这个分镜节点的版本;其它节点的改动不影响它)"), "nodeId": str("真实分镜脚本节点ID"), "action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove"}}, "rowId": str("update/remove 使用 canvas_read_storyboard 返回的真实 rowId;append 留空"), "patch": cloudAgentStoryboardPatchSchema(), }, "snapshotHash", "nodeId", "action") add("canvas_edit_batch_table", "操作批量创作表组件:追加、修改或删除任务行,切换批量换装/创意生图,设置1/5/10并发,新增或减少参考图列,或设置覆盖各任务的全局提示词。必须先用 canvas_read_batch_table 获取最新 snapshotHash 和真实 rowId。行 patch 仅允许 enabled、inputNodeIds、prompt;prompt 可使用读取结果中的 @参考图1、@参考图2 等 mentionToken 指代本行对应位置的图片。append 未传 inputNodeIds 时会继承上一行参考图;图片ID必须来自当前画布。不能写 outputNodeId、任务状态、URL、storageKey 或任意 metadata。本工具只编辑计划,不提交收费生成。", map[string]any{ - "snapshotHash": str("最近一次批量创作表读取返回的 snapshotHash"), + "snapshotHash": str("最近一次 canvas_read_batch_table 返回的 snapshotHash(这个表节点的版本;其它节点的改动不影响它)"), "nodeId": str("真实批量创作表节点ID"), "action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt"}}, "rowId": str("update/remove 使用 canvas_read_batch_table 返回的真实 rowId;其他操作留空"), @@ -1011,7 +1011,7 @@ func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloud if err != nil { return nil, err } - return cloudAgentStoryboardReadResult(view, args.NodeID) + return cloudAgentStoryboardReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) case "canvas_read_batch_table": var args struct { NodeID string `json:"nodeId"` @@ -1038,7 +1038,7 @@ func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloud if err != nil { return nil, err } - return cloudAgentBatchTableReadResult(view, args.NodeID) + return cloudAgentBatchTableReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) case "image_text_detect": var args struct { NodeID string `json:"nodeId"` diff --git a/backend/internal/app/cloud_agent_user_errors.go b/backend/internal/app/cloud_agent_user_errors.go new file mode 100644 index 000000000..587dc1d70 --- /dev/null +++ b/backend/internal/app/cloud_agent_user_errors.go @@ -0,0 +1,249 @@ +package app + +import ( + "encoding/json" + "errors" + "log" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" +) + +// 这组函数与前端 web/src/lib/generation-error.ts 的 generationErrorMessage 保持同一套分类, +// 让画布节点和 Agent 对同一个失败说同一句话:审核、存储、网络、HTTP 状态分别归类, +// 其余可读的供应商原因原样保留,而不是统一成"生成失败"。 + +const ( + taskErrorModerationMessage = "内容审核未通过,本次平台积分未扣除或已退还。请修改提示词后重新生成。" + taskErrorDefaultMessage = "生成失败,请稍后重试。" + taskErrorNetworkMessage = "网络异常。" +) + +var ( + taskErrorNetworkPattern = regexp.MustCompile(`(?i)\b(?:dial tcp|connection refused|connection reset|no such host|i/o timeout|context deadline exceeded|network error|failed to fetch|fetch failed|socket hang up|econnrefused|econnreset|etimedout|broken pipe|unexpected eof|tls handshake timeout)\b`) + taskErrorInfrastructurePattern = regexp.MustCompile(`(?i)(?:接口请求失败|Request failed with status code|https?://|\b(?:GET|POST|PUT|PATCH|DELETE)\s+["']?|Bad Gateway|Service Unavailable|Gateway Timeout|upstream_error|\bread tcp\b|\bwrite tcp\b)`) + taskErrorWrappedInterface = regexp.MustCompile(`(?s)^接口请求失败[::]\s*(.*)$`) + taskErrorWrappedRequest = regexp.MustCompile(`(?is)^Request failed with status code \d{3}\s*[::-]?\s*(.+)$`) + taskErrorWrappedStatusPrefix = regexp.MustCompile(`(?i)^\d{3}(?:\s+(?:Bad Gateway|Service Unavailable|Gateway Timeout|Internal Server Error|Not Found|Unauthorized|Forbidden|Too Many Requests))?\s*[::-]?\s*`) + taskErrorStorageDisabled = regexp.MustCompile(`(?i)\bUserDisable\b`) + taskErrorStorageUpload = regexp.MustCompile(`(?i)(?:参考(?:图片|媒体)上传失败|OSS 上传失败|对象存储|腾讯云 COS|七牛云)`) +) + +// userFacingTaskError 把任务里保存的原始错误转换成用户能看懂的中文原因。 +func userFacingTaskError(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return taskErrorDefaultMessage + } + if taskErrorIsModeration(raw) { + return taskErrorModerationText(raw) + } + providerMessage := taskErrorStructuredProviderMessage(raw) + if providerMessage == "" { + providerMessage = taskErrorWrappedProviderMessage(raw) + } + display := raw + if providerMessage != "" { + display = providerMessage + } + if taskErrorIsModeration(display) { + return taskErrorModerationText(display) + } + if message := taskErrorStorageMessage(raw); message != "" { + return message + } + if message := taskErrorStorageMessage(display); message != "" { + return message + } + if taskErrorNetworkPattern.MatchString(display) { + return taskErrorNetworkMessage + } + if providerMessage == "" { + if !strings.Contains(display, ";上游:") { + switch { + case taskErrorHasStatus(raw, "429"): + return "服务当前繁忙,请稍后重试。" + case taskErrorHasStatus(raw, "401", "403"): + return "生成服务鉴权失败,请检查渠道配置。" + case taskErrorHasStatus(raw, "404"): + return "生成服务地址不可用,请检查渠道配置。" + case taskErrorHasStatus(raw, "500", "502", "503", "504"): + return taskErrorNetworkMessage + } + } + if taskErrorInfrastructurePattern.MatchString(raw) { + return taskErrorNetworkMessage + } + } + return truncateRunes(display, 240) +} + +// userFacingTaskFailure 给任务失败加上"哪一步失败"的前缀,例如"图片生成失败:网络异常。" +func userFacingTaskFailure(task *model.Task) string { + if task == nil { + return "任务失败:" + taskErrorDefaultMessage + } + reason := userFacingTaskError(task.Error) + if task.Status == model.TaskStatusCancelled && strings.TrimSpace(task.Error) == "" { + reason = "任务已取消" + } + return userFacingTaskKind(task) + "失败:" + reason +} + +func userFacingTaskKind(task *model.Task) string { + switch { + case task.Operation == cloudAgentStepOperation || task.Operation == cloudAgentOperation || task.Operation == cloudAgentContextCompactionOperation: + return "模型调用" + case task.Type == "canvas_image" || strings.Contains(task.Type, "image"): + return "图片生成" + case task.Type == "canvas_video" || strings.HasPrefix(task.Type, "video_") || strings.Contains(task.Type, "video"): + return "视频生成" + case strings.Contains(task.Type, "audio"): + return "音频生成" + case task.Type == "canvas_text": + return "文本生成" + } + return "任务" +} + +// cloudAgentUserFailureMessage 决定 Agent 运行失败时展示给用户的文字。 +// 已经是中文、可读的原因(业务错误、任务失败原因)原样展示;内部英文诊断只写日志, +// 用户看到的是可操作的中文提示和诊断号,不会看到 Go/Node 的原始报错。 +func cloudAgentUserFailureMessage(runID string, cause error) string { + if cause == nil { + return "Agent 执行中断,请重新发送消息" + } + var appErr *AppError + if errors.As(cause, &appErr) && appErr != nil && cloudAgentReadableMessage(appErr.Message) { + return strings.TrimSpace(appErr.Message) + } + if message := cloudAgentInnermostMessage(cause); cloudAgentReadableMessage(message) { + return message + } + log.Printf("[Agent] internal failure run=%s: %v", runID, cause) + return "Agent 执行中断,请重新发送消息;如反复出现,请把诊断号 " + runID + " 反馈给管理员" +} + +// cloudAgentInnermostMessage 沿包装链找到最内层的错误文字。包装前缀("load session: " +// 之类)是给日志看的内部上下文,不展示给用户。 +func cloudAgentInnermostMessage(err error) string { + for err != nil { + next := errors.Unwrap(err) + if next == nil { + return strings.TrimSpace(err.Error()) + } + err = next + } + return "" +} + +// cloudAgentReadableMessage 只放行"以中文为主、不含地址/堆栈/凭证"的文字。 +func cloudAgentReadableMessage(message string) bool { + message = strings.TrimSpace(message) + if message == "" || !utf8.ValidString(message) || strings.ContainsAny(message, "\r\n\x00") { + return false + } + han, letters := 0, 0 + for _, r := range message { + switch { + case unicode.Is(unicode.Han, r): + han++ + case r < unicode.MaxASCII && unicode.IsLetter(r): + letters++ + } + } + if han == 0 || letters > han*2 { + return false + } + return safeTaskDiagnosticMessage(message) == truncateRunes(message, 240) && !taskErrorInfrastructurePattern.MatchString(message) && !taskErrorNetworkPattern.MatchString(message) +} + +func taskErrorIsModeration(value string) bool { + return strings.Contains(strings.ToLower(value), "sensitive_words_detected") || strings.Contains(value, "内容审核未通过") +} + +func taskErrorModerationText(raw string) string { + if index := strings.Index(raw, ";上游:"); index >= 0 && !taskErrorInfrastructurePattern.MatchString(raw) { + return taskErrorModerationMessage + raw[index:] + } + return taskErrorModerationMessage +} + +func taskErrorStorageMessage(value string) string { + switch { + case value == "": + return "" + case taskErrorStorageDisabled.MatchString(value): + return "对象存储账号已停用,请检查或更换对象存储配置。" + case taskErrorStorageUpload.MatchString(value): + return "参考素材上传到对象存储失败,请检查对象存储配置后重试。" + } + return "" +} + +func taskErrorHasStatus(value string, statuses ...string) bool { + for _, status := range statuses { + if regexp.MustCompile(`\b` + status + `\b`).MatchString(value) { + return true + } + } + return false +} + +func taskErrorStructuredProviderMessage(raw string) string { + for index := strings.Index(raw, "{"); index >= 0; { + var payload any + if json.Unmarshal([]byte(strings.TrimSpace(raw[index:])), &payload) == nil { + if message := taskErrorPayloadMessage(payload); message != "" { + return message + } + } + next := strings.Index(raw[index+1:], "{") + if next < 0 { + break + } + index += next + 1 + } + return "" +} + +func taskErrorWrappedProviderMessage(raw string) string { + var wrapped string + if match := taskErrorWrappedInterface.FindStringSubmatch(raw); match != nil { + wrapped = match[1] + } else if match := taskErrorWrappedRequest.FindStringSubmatch(raw); match != nil { + wrapped = match[1] + } else { + return "" + } + message := strings.TrimSpace(taskErrorWrappedStatusPrefix.ReplaceAllString(wrapped, "")) + if message == "" || taskErrorInfrastructurePattern.MatchString(message) { + return "" + } + return message +} + +func taskErrorPayloadMessage(payload any) string { + switch value := payload.(type) { + case string: + return strings.TrimSpace(value) + case map[string]any: + if nested, ok := value["error"].(map[string]any); ok { + if message := taskErrorPayloadMessage(nested); message != "" { + return message + } + } + for _, key := range []string{"message", "msg", "detail"} { + if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + if text, ok := value["error"].(string); ok { + return strings.TrimSpace(text) + } + } + return "" +} diff --git a/backend/internal/app/cloud_agent_user_errors_test.go b/backend/internal/app/cloud_agent_user_errors_test.go new file mode 100644 index 000000000..4ce7d7913 --- /dev/null +++ b/backend/internal/app/cloud_agent_user_errors_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "errors" + "fmt" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// 线上真实报错逐条回放:用户只能看到中文、可操作的原因,看不到地址、IP 或 Go 原始报错; +// 同时不能一律变成"生成失败"。 +func TestUserFacingTaskErrorMatchesCanvasNodeClassification(t *testing.T) { + for _, tc := range []struct{ name, raw, want string }{ + {"connection reset", `Post "https://ai.cangyuansuanli.cn/v1/images/generations": read tcp 192.168.1.7:61229->154.12.46.253:443: read: connection reset by peer`, "网络异常。"}, + {"dial", `dial tcp 10.0.0.1:443: connect: connection refused`, "网络异常。"}, + {"upstream 500 with reason", "模型服务暂时不可用(HTTP 500);上游:Upstream gateway error", "模型服务暂时不可用(HTTP 500);上游:Upstream gateway error"}, + {"bare 502", "HTTP 502 Bad Gateway", "网络异常。"}, + {"rate limit", "HTTP 429", "服务当前繁忙,请稍后重试。"}, + {"auth", "status 401", "生成服务鉴权失败,请检查渠道配置。"}, + {"provider json", `接口请求失败:{"error":{"message":"图片尺寸不支持"}}`, "图片尺寸不支持"}, + {"moderation", "sensitive_words_detected", taskErrorModerationMessage}, + {"storage", "参考图片上传失败:bucket denied", "参考素材上传到对象存储失败,请检查对象存储配置后重试。"}, + {"readable chinese", "模型服务拒绝了请求,请检查模型和参数", "模型服务拒绝了请求,请检查模型和参数"}, + {"empty", "", taskErrorDefaultMessage}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := userFacingTaskError(tc.raw); got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestUserFacingTaskFailureNamesTheStep(t *testing.T) { + task := &model.Task{Type: "canvas_image", Status: model.TaskStatusFailed, Error: `Post "https://x/v1/images/generations": read tcp 1.2.3.4:1->5.6.7.8:443: read: connection reset by peer`} + if got := userFacingTaskFailure(task); got != "图片生成失败:网络异常。" { + t.Fatalf("got %q", got) + } + step := &model.Task{Type: "canvas_text", Operation: cloudAgentStepOperation, Status: model.TaskStatusFailed, Error: "画布 Agent 接口没有返回内容"} + if got := userFacingTaskFailure(step); got != "模型调用失败:画布 Agent 接口没有返回内容" { + t.Fatalf("got %q", got) + } +} + +func TestCloudAgentUserFailureMessageHidesInternalEnglish(t *testing.T) { + internal := fmt.Errorf("load session: %w", errors.New("invalid Agent checkpoint (runtime validation): Agent runtime has multiple active tasks")) + got := cloudAgentUserFailureMessage("run-1", internal) + if strings.ContainsAny(got, "abcdefghijklmnopqrstuvwxyz") && !strings.Contains(got, "run-1") { + t.Fatalf("internal English leaked: %q", got) + } + if strings.Contains(got, "checkpoint") || strings.Contains(got, "multiple active") { + t.Fatalf("internal English leaked: %q", got) + } + readable := fmt.Errorf("wrap: %w", BadAuthRequest("图片生成失败:网络异常。")) + if got := cloudAgentUserFailureMessage("run-1", readable); got != "图片生成失败:网络异常。" { + t.Fatalf("readable reason lost: %q", got) + } + if got := cloudAgentUserFailureMessage("run-1", fmt.Errorf("x: %w", repository.ErrInsufficientCredits)); got != "积分不足,请先充值" { + t.Fatalf("insufficient credits not localized: %q", got) + } +} + +// 所有仓储层哨兵错误都必须是中文:它们会经 handler 直接返回给用户。 +func TestRepositorySentinelErrorsAreChinese(t *testing.T) { + for _, err := range []error{ + repository.ErrCreationConflict, repository.ErrInsufficientCredits, repository.ErrRedeemCodeInvalid, + repository.ErrActiveTaskLimit, repository.ErrTaskNotRetryable, repository.ErrBillingStateConflict, + repository.ErrBillingUsageUnavailable, repository.ErrBillingChargeLimit, repository.ErrChannelModelInUse, + repository.ErrLogicalModelInUse, repository.ErrLogicalModelUnavailable, repository.ErrTaskStateConflict, + repository.ErrCanvasRevisionConflict, repository.ErrDailyUploadLimitExceeded, repository.ErrProjectHasActiveTasks, + repository.ErrPaymentOrderStateConflict, repository.ErrPaymentEvidenceMismatch, + } { + if !cloudAgentReadableMessage(err.Error()) { + t.Errorf("sentinel error is not user-readable Chinese: %q", err.Error()) + } + } +} diff --git a/backend/internal/app/creation_test.go b/backend/internal/app/creation_test.go index 5c225868d..1c46f362f 100644 --- a/backend/internal/app/creation_test.go +++ b/backend/internal/app/creation_test.go @@ -25,11 +25,18 @@ func creationTestService(t *testing.T) (*Service, *gorm.DB, string, CreationGuar if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = sqlDB.Close() }) if err = db.AutoMigrate(database.Models()...); err != nil { t.Fatal(err) } - s := &Service{repo: repository.New(db), dataDir: t.TempDir()} + s := &Service{ + repo: repository.New(db), dataDir: t.TempDir(), + disablePiRuntime: true, legacyCloudAgentRootTask: true, + } + t.Cleanup(func() { + s.closeCloudAgentPiRunners() + s.closeApprovedCloudAgentMediaWaiters() + _ = sqlDB.Close() + }) capabilityConfig := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-test")) for _, item := range []any{&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Enabled: true, Name: "受控测试"}, &model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: capabilityConfig, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000}} { if err = db.Create(item).Error; err != nil { diff --git a/backend/internal/app/platform_bridge.go b/backend/internal/app/platform_bridge.go index 7ce0024cc..db25de6cc 100644 --- a/backend/internal/app/platform_bridge.go +++ b/backend/internal/app/platform_bridge.go @@ -12,6 +12,7 @@ import ( type ( RuntimeResourcePolicy = platform.RuntimeResourcePolicy + RuntimeStoragePolicy = platform.RuntimeStoragePolicy RuntimeTaskPolicy = platform.RuntimeTaskPolicy RuntimeRequestPolicy = platform.RuntimeRequestPolicy RuntimePolicySetting = platform.RuntimePolicySetting @@ -148,7 +149,12 @@ func (s *Service) RecordChannelResult(ctx context.Context, channelID string, fai } func (s *Service) Close() error { - if s == nil || s.coordinator == nil { + if s == nil { + return nil + } + s.closeCloudAgentPiRunners() + s.closeApprovedCloudAgentMediaWaiters() + if s.coordinator == nil { return nil } if !s.coordinator.HasRedis() { diff --git a/backend/internal/app/provider_agent_protocol.go b/backend/internal/app/provider_agent_protocol.go index 96dd9d6ca..5a5e7a757 100644 --- a/backend/internal/app/provider_agent_protocol.go +++ b/backend/internal/app/provider_agent_protocol.go @@ -84,6 +84,8 @@ func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerC } request := *source request.Messages = messages + // 同名工具会被多数供应商直接拒绝(400)。按名称去重,保留第一次出现的定义。 + request.Tools = dedupeCanonicalAgentTools(source.Tools) result := &agentToolRequests{} if declarative { result.ChatCompletion = canonicalAgentChatBody(&request, false) @@ -423,3 +425,18 @@ func canonicalAgentText(content interface{}) string { } return strings.Join(texts, "\n") } + +func dedupeCanonicalAgentTools(tools []map[string]interface{}) []map[string]interface{} { + seen := make(map[string]bool, len(tools)) + result := make([]map[string]interface{}, 0, len(tools)) + for _, tool := range tools { + function, _ := tool["function"].(map[string]interface{}) + name := stringField(function, "name") + if seen[name] { + continue + } + seen[name] = true + result = append(result, tool) + } + return result +} diff --git a/backend/internal/app/provider_text.go b/backend/internal/app/provider_text.go index 7bfdc322f..8b53ea804 100644 --- a/backend/internal/app/provider_text.go +++ b/backend/internal/app/provider_text.go @@ -606,7 +606,7 @@ func (p *streamingAgentParser) consumeChatCompletionEvent(payload map[string]int choice, _ := value.(map[string]interface{}) delta, _ := choice["delta"].(map[string]interface{}) p.appendText(streamContentText(delta["content"])) - p.appendReasoning(firstNonEmptyString(stringField(delta, "reasoning_content"), stringField(delta, "reasoning"), stringField(delta, "reasoning_text"))) + 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) @@ -633,7 +633,7 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} case "text": p.appendText(stringField(block, "text")) case "thinking": - p.appendReasoning(firstNonEmptyString(stringField(block, "thinking"), stringField(block, "text"))) + p.appendReasoning(firstRawString(stringField(block, "thinking"), stringField(block, "text"))) case "tool_use": arguments := "" if input := block["input"]; input != nil { @@ -650,7 +650,7 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} p.appendText(stringField(delta, "text")) } if stringField(delta, "type") == "thinking_delta" { - p.appendReasoning(firstNonEmptyString(stringField(delta, "thinking"), stringField(delta, "text"))) + p.appendReasoning(firstRawString(stringField(delta, "thinking"), stringField(delta, "text"))) } if stringField(delta, "type") == "input_json_delta" { p.toolCall(index).arguments += stringField(delta, "partial_json") @@ -1404,3 +1404,14 @@ func extractChatCompletionText(payload map[string]interface{}) string { } 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/service.go b/backend/internal/app/service.go index 763e1d3e2..1d2f5c1ae 100644 --- a/backend/internal/app/service.go +++ b/backend/internal/app/service.go @@ -78,6 +78,18 @@ type Service struct { auth *auth.Service sms *sms.Service canvas *canvas.Service + piRunnerMu sync.Mutex + piRunnerWg sync.WaitGroup + piRunners map[string]context.CancelFunc + piRunnersClosed bool + disablePiRuntime bool + // legacyCloudAgentRootTask is enabled only by tests that exercise the pre-Pi + // model-worker path. Runtime availability must not change root task semantics. + legacyCloudAgentRootTask bool + approvedMediaMu sync.Mutex + approvedMediaWg sync.WaitGroup + approvedMediaWaiters map[string]context.CancelFunc + approvedMediaClosed bool } const taskWorkerConcurrency = 3 @@ -117,7 +129,7 @@ func newService(repo *repository.Repository, dataDir string) *Service { paymentRegistry = dynamic } } - service := &Service{repo: repo, dataDir: dataDir, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), agentConflictStreak: make(map[string]int), agentSchedulerWake: make(chan struct{}, 1), taskDispatcherWake: make(chan struct{}, 1), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: paymentRegistry, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time), geminiCacheLocks: make(map[string]*geminiCacheKeyLock)} + service := &Service{repo: repo, dataDir: dataDir, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), agentConflictStreak: make(map[string]int), agentSchedulerWake: make(chan struct{}, 1), taskDispatcherWake: make(chan struct{}, 1), piRunners: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: paymentRegistry, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time), geminiCacheLocks: make(map[string]*geminiCacheKeyLock)} service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo) service.taskTerminalCoordinator = newTaskTerminalCoordinator(service) service.taskRouteExecutor = newTaskRouteExecutor(service) @@ -152,6 +164,18 @@ func (s *Service) StartWorker() { s.startResourceDeletionWorker(ctx) s.startSkillSyncWorker(ctx) s.startPaymentWorker(ctx) + s.runWorkerLoop(func(ctx context.Context) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + s.recoverCloudAgentPiRunners() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }) } func (s *Service) BeginDrain() { s.backgroundWorkers().BeginDrain() } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 132a1d00d..405e42303 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -18,6 +18,7 @@ type taskAdmission struct { AgentRunID string GenerationID string ApprovalID string + NonBillable bool } // CreateTask 收敛任务进入系统前的 admission 流程:输入标准化、逻辑模型路由、 @@ -136,6 +137,26 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil { return nil, err } + // Agent roots are durable control-plane carriers, not model calls. They + // must not reserve credits or enter the worker queue; Pi creates the + // billable cloud_agent_step task for each actual model request instead. + if req.admission != nil && req.admission.NonBillable && !s.legacyCloudAgentRootTask { + if err := s.protectTaskSecrets(normalizedInput); err != nil { + return nil, err + } + inputJSON, err := json.Marshal(normalizedInput) + if err != nil { + return nil, fmt.Errorf("序列化任务输入失败:%w", err) + } + task.Status = model.TaskStatusTextReplay + task.Stage = "Agent 控制面载体" + task.Progress = 0 + task.InputJSON = string(inputJSON) + if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil { + return nil, err + } + return taskForOutput(task), nil + } billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput) if err != nil { return nil, err diff --git a/backend/internal/app/task_worker.go b/backend/internal/app/task_worker.go index ca8a36fa9..cc28ca233 100644 --- a/backend/internal/app/task_worker.go +++ b/backend/internal/app/task_worker.go @@ -52,25 +52,6 @@ func (w *taskWorkerCoordinator) start(ctx context.Context) { s.startProviderCancellationReconciliation(ctx) s.startBillingReviewAudit(ctx) s.startAgentMemoryCompactScheduler() - s.runWorkerLoop(func(ctx context.Context) { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - if ctx.Err() != nil { - return - } - if !s.IsDraining() { - s.advanceCloudAgents() - } - select { - case <-ctx.Done(): - return - case <-s.agentSchedulerWake: - continue - case <-ticker.C: - } - } - }) s.runWorkerLoop(func(ctx context.Context) { slots := make(chan struct{}, maxChannelConcurrencyLimit) dispatch := func() { @@ -149,9 +130,6 @@ func (w *taskWorkerCoordinator) processNextTask() error { func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot *platform.SlotLease) error { s := w.service - if task != nil && task.Operation == cloudAgentStepOperation { - defer s.wakeCloudAgentScheduler() - } terminal := s.terminalCoordinator() policyCtx, cancelPolicy := context.WithTimeout(context.Background(), 3*time.Second) reader := &Service{repo: s.repo.WithContext(policyCtx)} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 3bbecd500..ce560edef 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 41 +const CurrentSchemaVersion int64 = 42 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -139,6 +139,9 @@ var schemaMigrations = []migration{ {version: 41, name: "resource_thumbnail", checksum: resourceThumbnailChecksum, apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.Resource{}) }}, + {version: 42, name: "cloud_agent_pi_sessions", checksum: "sha256:cloud-agent-pi-sessions-v42-20260928", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.CloudAgentPiSession{}) + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 0f2d7f5ec..0f982a32b 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -17,6 +17,7 @@ import ( func Models() []any { return []any{ &model.CloudAgentExecution{}, + &model.CloudAgentPiSession{}, &model.CloudAgentEventRecord{}, &model.CloudAgentMessageRecord{}, &model.CloudAgentCanvasMutation{}, diff --git a/backend/internal/model/cloud_agent.go b/backend/internal/model/cloud_agent.go index 822a91e9e..934e2ed77 100644 --- a/backend/internal/model/cloud_agent.go +++ b/backend/internal/model/cloud_agent.go @@ -27,6 +27,17 @@ type CloudAgentExecution struct { UpdatedAt time.Time } +// CloudAgentPiSession stores Pi's native JSONL session independently from the +// bounded control checkpoint. Pi owns transcript and compaction semantics; +// Go only persists the opaque session snapshot and its revision. +type CloudAgentPiSession struct { + RunID string `gorm:"primaryKey;size:80"` + UserID string `gorm:"index;size:36;not null"` + SessionJSONL string `gorm:"column:session_jsonl;type:text;not null"` + Revision int64 `gorm:"not null;default:1"` + UpdatedAt time.Time +} + // Journal rows are append-only and commit in the same transaction as the run. type CloudAgentEventRecord struct { RunID string `gorm:"primaryKey;size:80"` diff --git a/backend/internal/repository/cloud_agent.go b/backend/internal/repository/cloud_agent.go index afecbf820..45252c1e0 100644 --- a/backend/internal/repository/cloud_agent.go +++ b/backend/internal/repository/cloud_agent.go @@ -11,6 +11,35 @@ import ( "infinite-canvas/backend/internal/model" ) +func (r *Repository) CloudAgentPiSession(userID, runID string) (*model.CloudAgentPiSession, error) { + var session model.CloudAgentPiSession + err := r.db.First(&session, "run_id = ? AND user_id = ?", runID, userID).Error + return &session, err +} + +// SaveCloudAgentPiSession uses a revision predicate so a stale/recovered Pi +// process cannot overwrite a newer native session snapshot. +func (r *Repository) SaveCloudAgentPiSession(session *model.CloudAgentPiSession, expectedRevision int64) error { + if session == nil || session.RunID == "" || session.UserID == "" { + return fmt.Errorf("invalid Agent session identity") + } + if expectedRevision == 0 { + session.Revision = 1 + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(session).Error + } + result := r.db.Model(&model.CloudAgentPiSession{}). + Where("run_id = ? AND user_id = ? AND revision = ?", session.RunID, session.UserID, expectedRevision). + Updates(map[string]any{"session_jsonl": session.SessionJSONL, "revision": expectedRevision + 1, "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + session.Revision = expectedRevision + 1 + return nil +} + func (r *Repository) EnsureCloudAgent(run *model.CloudAgentExecution) error { if run.ConversationID == "" { run.ConversationID = run.ID @@ -29,12 +58,18 @@ func (r *Repository) EnsureCloudAgent(run *model.CloudAgentExecution) error { } return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(run).Error } + +// CloudAgent 在同一个只读事务里读取执行行和它的事件/消息行。分开读的话,并发写入 +// (运行时事件、工具结果)可能落在两次查询之间:读到旧的计数配新的行,解码时会被 +// 当成"记录不完整"而把一次正常运行判死。 func (r *Repository) CloudAgent(userID, id string) (*model.CloudAgentExecution, error) { var run model.CloudAgentExecution - err := r.db.First(&run, "id = ? AND user_id = ?", id, userID).Error - if err == nil { - err = r.hydrateCloudAgent(&run) - } + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.First(&run, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return err + } + return New(tx).hydrateCloudAgent(&run) + }) return &run, err } @@ -106,10 +141,12 @@ func (r *Repository) CloudAgentEventRecordCount(userID, runID string) (int64, er func (r *Repository) CloudAgentForActiveTask(userID, taskID string) (*model.CloudAgentExecution, error) { var run model.CloudAgentExecution - err := r.db.Where("user_id = ? AND active_task_id = ? AND status IN ?", userID, taskID, []string{"running", "queued"}).First(&run).Error - if err == nil { - err = r.hydrateCloudAgent(&run) - } + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ? AND active_task_id = ? AND status IN ?", userID, taskID, []string{"running", "queued"}).First(&run).Error; err != nil { + return err + } + return New(tx).hydrateCloudAgent(&run) + }) return &run, err } func (r *Repository) CloudAgentRoots() ([]model.Task, error) { @@ -124,14 +161,18 @@ func (r *Repository) ActiveCloudAgentsAfter(after string, limit int) ([]model.Cl if limit < 1 || limit > 50 { limit = 50 } - err := r.db.Where("(status IN ? OR cleanup_pending = ?) AND id > ?", []string{"running", "queued"}, true, after).Order("id").Limit(limit).Find(&runs).Error - if err == nil { + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("(status IN ? OR cleanup_pending = ?) AND id > ?", []string{"running", "queued"}, true, after).Order("id").Limit(limit).Find(&runs).Error; err != nil { + return err + } + scoped := New(tx) for i := range runs { - if err = r.hydrateCloudAgent(&runs[i]); err != nil { - break + if err := scoped.hydrateCloudAgent(&runs[i]); err != nil { + return err } } - } + return nil + }) return runs, err } diff --git a/backend/internal/repository/cloud_agent_pi_session_test.go b/backend/internal/repository/cloud_agent_pi_session_test.go new file mode 100644 index 000000000..bba0f1038 --- /dev/null +++ b/backend/internal/repository/cloud_agent_pi_session_test.go @@ -0,0 +1,51 @@ +package repository + +import ( + "errors" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCloudAgentPiSessionSnapshotRevision(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:cloud-agent-pi-session-revision?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.CloudAgentPiSession{}); err != nil { + t.Fatal(err) + } + repo := New(db) + session := &model.CloudAgentPiSession{RunID: "run-a", UserID: "user-a", SessionJSONL: "{\"type\":\"session\"}\n", UpdatedAt: time.Now()} + if err := repo.SaveCloudAgentPiSession(session, 0); err != nil { + t.Fatal(err) + } + if session.Revision != 1 { + t.Fatalf("create revision = %d, want 1", session.Revision) + } + session.SessionJSONL = "{\"type\":\"session\",\"updated\":true}\n" + if err := repo.SaveCloudAgentPiSession(session, 1); err != nil { + t.Fatal(err) + } + if session.Revision != 2 { + t.Fatalf("update revision = %d, want 2", session.Revision) + } + stale := &model.CloudAgentPiSession{RunID: "run-a", UserID: "user-a", SessionJSONL: "stale"} + if err := repo.SaveCloudAgentPiSession(stale, 1); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("stale update error = %v, want revision conflict", err) + } + stored, err := repo.CloudAgentPiSession("user-a", "run-a") + if err != nil { + t.Fatal(err) + } + if stored.SessionJSONL != session.SessionJSONL || stored.Revision != 2 { + t.Fatalf("stale writer changed session: %+v", stored) + } + if _, err := repo.CloudAgentPiSession("user-b", "run-a"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("cross-user lookup error = %v, want not found", err) + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index c8445beee..3601bfde7 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -19,11 +19,11 @@ description: 影策后端核心持久化表与状态边界 ## 结构迁移与版本 -当前结构版本为 40。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 +当前结构版本为 42。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 -升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 40,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。 +升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 42,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。 ### 生成成功后的作品保存恢复 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index cc0a284c8..94fabd625 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -16,7 +16,7 @@ description: 从产品功能快速定位主要代码入口 ## 前端页面 -云端 Agent:`web/src/components/canvas/canvas-cloud-agent-panel.tsx` 协调对话、Skills 和审批,`canvas-cloud-agent-settings.tsx` 编辑长期偏好与个人记忆,`services/api/agent.ts` 适配运行快照与事件流;画布手工编辑与引用同步共用 `web/src/lib/canvas/canvas-node-prompt.ts`,已有媒体结果只更新 `composerContent` 草稿,不覆盖生成结果和已提交提示词。后端 `handler/agent.go` 负责 HTTP 合同,`app/cloud_agent.go` 负责权限、幂等、可信历史,`app/cloud_agent_profile.go` / `repository/agent_profile.go` 管理用户、项目、画布偏好及 revision CAS,`app/cloud_agent_lessons.go` / `app/cloud_agent_memory_compact.go` / `repository/agent_lesson.go` / `handler/agent_lesson.go` 管理用户私有记忆的写入脱敏、召回注入、用户自审批与压缩优化,`web/src/pages/settings/agent-memory-pane.tsx` 是用户批准/添加/导入导出/压缩入口,`web/src/pages/admin/components/agent-lessons-panel.tsx` 是管理员按用户查看入口,`internal/prompts/agent-*-policy.md` 是随发布物编译、并在文档头声明 ID/版本的行为资源,`app/cloud_agent_policy.go` 编译本轮策略快照,`internal/canvas/capability/` 注册服务端节点能力和字段映射,`app/cloud_agent_canvas_state.go` 投影画布摘要和精读,`app/cloud_agent_runtime.go` 负责持久化模型循环,`app/cloud_agent_tools.go` 根据能力定义生成工具 schema,并从固定技能快照渐进读取 `SKILL.md` / reference,`app/cloud_agent_media.go` 负责收费媒体任务适配,`app/task_creation.go` 执行预算 admission,运行沿用任务 Worker。节点新增需要能力描述符和真实画布实现;新生成模式还需任务适配器,不由偏好或技能 md 授权。协议见 HTTP API 专题,未确认的真实登录态与上游联调见待测试清单。 +云端 Agent:`web/src/components/canvas/canvas-cloud-agent-panel.tsx` 协调对话、Skills 和审批,`canvas-cloud-agent-settings.tsx` 编辑长期偏好与个人记忆,`services/api/agent.ts` 适配运行快照与事件流;画布手工编辑与引用同步共用 `web/src/lib/canvas/canvas-node-prompt.ts`,已有媒体结果只更新 `composerContent` 草稿,不覆盖生成结果和已提交提示词。后端 `handler/agent.go` 负责 HTTP 合同,`app/cloud_agent.go` 负责权限、幂等、可信历史,`app/cloud_agent_profile.go` / `repository/agent_profile.go` 管理用户、项目、画布偏好及 revision CAS,`app/cloud_agent_lessons.go` / `app/cloud_agent_memory_compact.go` / `repository/agent_lesson.go` / `handler/agent_lesson.go` 管理用户私有记忆的写入脱敏、召回注入、用户自审批与压缩优化,`web/src/pages/settings/agent-memory-pane.tsx` 是用户批准/添加/导入导出/压缩入口,`web/src/pages/admin/components/agent-lessons-panel.tsx` 是管理员按用户查看入口,`internal/prompts/agent-*-policy.md` 是随发布物编译、并在文档头声明 ID/版本的行为资源,`app/cloud_agent_policy.go` 编译本轮策略快照,`internal/canvas/capability/` 注册服务端节点能力和字段映射,`app/cloud_agent_canvas_state.go` 投影画布摘要和精读,`app/cloud_agent_runtime.go` / `app/cloud_agent_pi_coordinator.go` 负责可恢复编排,原生 Pi runtime 协议适配位于 `internal/agent/runtime/` 与 `agent-runtime/pi/agent-runtime.mjs`;`cloud_agent_pi_sessions` 独立保存原生 JSONL 会话,控制检查点仍由 Go 管理。工具根据能力定义生成 schema,并从固定技能快照渐进读取 `SKILL.md` / reference;`app/cloud_agent_media.go` 负责收费媒体任务适配,`app/task_creation.go` 执行预算 admission,运行沿用任务 Worker。节点新增需要能力描述符和真实画布实现;新生成模式还需任务适配器,不由偏好或技能 md 授权。协议见 HTTP API 专题,未确认的真实登录态与上游联调见待测试清单。 | 功能 | 页面入口 | 主要协作模块 | | --- | --- | --- | diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 4f4f0f96a..936144d8c 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -691,7 +691,6 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se const result = submission.parentRunId ? await sendAgentMessage(submission.parentRunId, request) : await createAgentRun(request); accepted = true; if (currentScope.current === scope) { - setContextUsage(emptyAgentContextUsage(result.run.id)); setRun(result.run); } await clearCloudAgentPendingSubmission(canvasId, activeConversationId); @@ -1248,7 +1247,7 @@ function AgentContextRing({ view }: { view: AgentContextUsageView }) { getPopupContainer={(trigger) => trigger.closest(".canvas-agent-panel") ?? document.body} content={
- 下一次请求 + 下一次请求 · 上下文窗口占用
{usageHeading} {view.phase !== "ok" ? {phaseLabel} : null} @@ -1268,7 +1267,7 @@ function AgentContextRing({ view }: { view: AgentContextUsageView }) { )}
- 输入预算占用 + 上下文窗口占用 {percent}
diff --git a/web/src/lib/canvas/agent-canvas-patch.ts b/web/src/lib/canvas/agent-canvas-patch.ts index beef81d7e..baef0f0d3 100644 --- a/web/src/lib/canvas/agent-canvas-patch.ts +++ b/web/src/lib/canvas/agent-canvas-patch.ts @@ -87,15 +87,26 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas const changes = (before: T[], after: T[]): Change[] => { const byId = new Map(before.map((item) => [item.id, item])); const afterIds = new Set(after.map((item) => item.id)); - return [ - ...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })), - ...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null })), - ]; + return [...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })), ...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null }))]; }; - return applyAgentCanvasPatch({ ...previous, nodes, connections }, { - canvasId: previous.id, - updatedAt: incoming.updatedAt, - nodes: changes(previous.nodes, incoming.nodes), - connections: changes(previous.connections, incoming.connections), - }); + const projected = applyAgentCanvasPatch( + { ...previous, nodes, connections }, + { + canvasId: previous.id, + updatedAt: incoming.updatedAt, + nodes: changes(previous.nodes, incoming.nodes), + connections: changes(previous.connections, incoming.connections), + }, + ); + const merged = { ...projected } as CanvasProject & Record; + const before = previous as CanvasProject & Record; + const after = incoming as CanvasProject & Record; + const editor = { ...previous, nodes, connections } as CanvasProject & Record; + for (const key of new Set([...Object.keys(before), ...Object.keys(after), ...Object.keys(editor)])) { + if (["id", "revision", "updatedAt", "remoteContentHash", "viewport", "nodes", "connections"].includes(key)) continue; + const value = mergeValue(editor[key], before[key], after[key]); + if (value === undefined) delete merged[key]; + else merged[key] = value; + } + return { ...merged, revision: incoming.revision, updatedAt: incoming.updatedAt, viewport: editor.viewport } as CanvasProject; } diff --git a/web/src/lib/canvas/agent-context-usage.ts b/web/src/lib/canvas/agent-context-usage.ts index c4a605105..5a3430745 100644 --- a/web/src/lib/canvas/agent-context-usage.ts +++ b/web/src/lib/canvas/agent-context-usage.ts @@ -27,11 +27,11 @@ export type AgentContextBreakdownItem = { export type AgentContextUsageView = { phase: AgentContextPhase; - /** Share of the input budget, 0–1 when the model window is known. */ + /** Share of the model context window, 0–1 when the window is known. */ ratio?: number; - /** Where semantic compaction starts, as a share of the same input budget. */ + /** Compaction threshold as a share of the model context window. */ compactRatio?: number; - /** Ring fill. 1 means "at or past the compaction line", not "window is full". */ + /** Ring fill against the full context window. */ ring: number; label: string; detail: string; @@ -74,8 +74,8 @@ export function finiteContextNumber(value: unknown): number | undefined { /** Returns a ratio only when the backend has a trustworthy configured model budget. */ export function contextPressureRatio(reading: Record | null): number | undefined { if (!reading || reading.modelLimitConfigured !== true) return undefined; - const usable = finiteContextNumber(reading.usableInputTokens); - if (!usable || usable <= 0) return undefined; + const contextWindow = finiteContextNumber(reading.contextWindowTokens); + if (!contextWindow || contextWindow <= 0) return undefined; const ratio = reading.tokenSource === "provider" ? finiteContextNumber(reading.projectedPressureRatio) : finiteContextNumber(reading.pressureRatio); @@ -128,15 +128,14 @@ function formatContextTokens(tokens: number | undefined): string { /** * One view of the compaction mechanism. - * The ring fills against compactAtTokens (85% of the input budget): full means - * the next model call is about to pause and compact, not that the window is 100% used. + * All visual scales use the model context window; the marker shows the shared 80% compaction line. */ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContextUsageView { const reading = usage.reading; const inputTokens = contextInputTokens(reading); - const usableTokens = finiteContextNumber(reading?.usableInputTokens); - const compactAtTokens = finiteContextNumber(reading?.compactAtTokens); const contextWindowTokens = finiteContextNumber(reading?.contextWindowTokens); + const usableTokens = contextWindowTokens; + const compactAtTokens = finiteContextNumber(reading?.compactAtTokens); const ratio = contextPressureRatio(reading); const compactRatio = usableTokens && compactAtTokens ? Math.min(1, compactAtTokens / usableTokens) : undefined; const tokenSource = reading?.tokenSource === "provider" ? "provider" : reading ? "estimate" : undefined; @@ -168,21 +167,21 @@ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContext } if (!reading) return base; if (usage.readingStale) { - return { ...base, phase: "stale", ring: ratio === undefined ? 0 : Math.min(1, ratio / (compactRatio || 0.85)), label: "刚压缩", detail: "上一份读数是压缩前的;下一次模型调用会给出压缩后的占用。" }; + return { ...base, phase: "stale", ring: ratio === undefined ? 0 : Math.min(1, ratio), label: "刚压缩", detail: "上一份读数是压缩前的;下一次模型调用会给出压缩后的占用。" }; } if (ratio === undefined || usableTokens === undefined || usableTokens <= 0) { const measured = inputTokens === undefined ? "窗口未知" : `约 ${formatContextTokens(inputTokens)} Token`; return { ...base, phase: "unknown", label: measured, detail: "这个模型没有配置可确认的上下文窗口,不能给出占用百分比;对话过长时仍会按条数和体积压缩。" }; } - const line = compactRatio && compactRatio > 0 ? compactRatio : 0.85; - const ring = Math.max(0, Math.min(1, ratio / line)); + const line = compactRatio && compactRatio > 0 ? compactRatio : 0.8; + const ring = Math.max(0, Math.min(1, ratio)); const percent = Math.round(ratio * 100); const source = estimate ? "本地估算" : "模型实测校准"; if (ratio >= line) { - return { ...base, phase: "compress", ring: 1, label: `${percent}%`, detail: `已到压缩线(输入预算的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。` }; + return { ...base, phase: "compress", ring, label: `${percent}%`, detail: `已到压缩线(模型窗口的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。` }; } if (ring >= 0.72) { return { ...base, phase: "watch", ring, label: `${percent}%`, detail: `接近压缩。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source}),到 ${formatContextTokens(compactAtTokens)} 时开始压缩。` }; } - return { ...base, phase: "ok", ring, label: `${percent}%`, detail: `当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。满格表示到达压缩线,不是窗口已经 100% 用完。` }; + return { ...base, phase: "ok", ring, label: `${percent}%`, detail: `当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。标记线为自动压缩阈值。` }; } diff --git a/web/test/agent-context-usage.test.ts b/web/test/agent-context-usage.test.ts index 2c1d6546c..9a2132131 100644 --- a/web/test/agent-context-usage.test.ts +++ b/web/test/agent-context-usage.test.ts @@ -8,6 +8,7 @@ describe("Agent context usage events", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { modelLimitConfigured: true, usableInputTokens: 80_000, + contextWindowTokens: 128_000, pressureTokens: 40_000, projectedNextInputTokens: 48_000, projectedPressureRatio: 0.6, @@ -27,19 +28,19 @@ describe("Agent context usage events", () => { }); it("marks pre-compaction readings stale until a fresh pressure event arrives", () => { - let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100 }, 2)); + let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 2)); state = reduceAgentContextUsage(state, event("run-1", "context_compaction_requested", { basis: "tokens" }, 3)); expect(state.compactionPending).toEqual({ basis: "tokens" }); state = reduceAgentContextUsage(state, event("run-1", "context_compacted", { mode: "checkpoint", resume: true }, 4)); expect(state.readingStale).toBe(true); expect(state.compactionPending).toBeNull(); expect(state.lastCompaction).toEqual({ mode: "checkpoint", resume: true }); - state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100 }, 5)); + state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 5)); expect(state.readingStale).toBe(false); expect(contextPressureRatio(state.reading)).toBe(0.25); }); - it("fills the ring against the compaction line, not the raw window", () => { + it("fills the ring against the model context window and keeps the compaction line as a marker", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { modelLimitConfigured: true, tokenSource: "provider", @@ -56,10 +57,10 @@ describe("Agent context usage events", () => { })); const view = presentAgentContextUsage(state); expect(view.phase).toBe("ok"); - expect(view.ring).toBeCloseTo(0.5, 5); + expect(view.ring).toBeCloseTo(0.425, 5); expect(view.label).toBe("43%"); expect(view.inputTokens).toBe(42_500); - expect(view.remainingTokens).toBe(57_500); + expect(view.remainingTokens).toBe(85_500); expect(view.protocolBytes).toBe(558); expect(view.breakdown.map((item) => item.label)).toEqual(["系统提示(含画布摘要)", "工具 schema", "会话消息(含工具结果)"]); expect(view.breakdown[0]?.tokens).toBe(9_000); @@ -70,12 +71,13 @@ describe("Agent context usage events", () => { let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { modelLimitConfigured: true, usableInputTokens: 100, + contextWindowTokens: 100, compactAtTokens: 85, estimatedInputTokens: 90, pressureRatio: 0.9, })); expect(presentAgentContextUsage(state).phase).toBe("compress"); - expect(presentAgentContextUsage(state).ring).toBe(1); + expect(presentAgentContextUsage(state).ring).toBe(0.9); state = reduceAgentContextUsage(state, event("run-1", "context_compaction_requested", { basis: "tokens" }, 2)); expect(presentAgentContextUsage(state).phase).toBe("compacting"); state = reduceAgentContextUsage(state, event("run-1", "context_compacted", { mode: "fallback", resume: true }, 3));