@xiaohhhh1/canvas-agent 0.4.81 → 0.4.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import WebSocket from "ws";
2
+ import { relayLocalRequest } from "./relay-request.js";
2
3
  const DEFAULT_RELAY_URL = "wss://canvas.xiaohhhh1.com/api/agent-relay";
3
4
  const RECONNECT_DELAY_MS = 3_000;
4
5
  const HEARTBEAT_INTERVAL_MS = 15_000;
@@ -78,18 +79,20 @@ export function startRelayBridge(config, options = {}) {
78
79
  const headers = new Headers(message.headers || {});
79
80
  headers.set("x-canvas-agent-token", config.token);
80
81
  const body = message.bodyBase64 ? Buffer.from(message.bodyBase64, "base64") : undefined;
81
- const response = await fetch(target, { method: message.method || "GET", headers, body });
82
- const bytes = Buffer.from(await response.arrayBuffer());
82
+ const response = await relayLocalRequest(target, message.method || "GET", headers, body);
83
83
  send({
84
84
  type: "response",
85
85
  id: message.id,
86
86
  status: response.status,
87
- contentType: response.headers.get("content-type") || "application/octet-stream",
88
- bodyBase64: bytes.toString("base64"),
87
+ contentType: response.contentType,
88
+ bodyBase64: response.bytes.toString("base64"),
89
89
  });
90
90
  }
91
91
  catch (error) {
92
- send({ type: "response", id: message.id, status: 502, contentType: "application/json", bodyBase64: Buffer.from(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "Agent relay request failed" })).toString("base64") });
92
+ const detail = error instanceof Error && error.message.startsWith("本机处理等待超时")
93
+ ? error.message
94
+ : "本机 Agent 请求连接中断;原视频仍保留,请恢复连接后重试。";
95
+ send({ type: "response", id: message.id, status: 502, contentType: "application/json", bodyBase64: Buffer.from(JSON.stringify({ ok: false, error: detail })).toString("base64") });
93
96
  }
94
97
  };
95
98
  const onMessage = (raw) => {
@@ -0,0 +1,6 @@
1
+ export declare function relayRequestTimeoutMs(pathname: string): number;
2
+ export declare function relayLocalRequest(target: URL, method: string, headers: Headers, body?: Buffer, timeoutMs?: number): Promise<{
3
+ status: number;
4
+ contentType: string;
5
+ bytes: Buffer;
6
+ }>;
@@ -0,0 +1,33 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ export function relayRequestTimeoutMs(pathname) {
4
+ if (["/agent/integrations/fastmoss/capture", "/agent/integrations/fastmoss/video-learning/capture"].includes(pathname))
5
+ return 25 * 60_000;
6
+ if (["/agent/video-intelligence/analyze", "/agent/video-intelligence/archive"].includes(pathname))
7
+ return 12 * 60_000;
8
+ return 20_000;
9
+ }
10
+ // Native fetch has a separate five-minute response-header deadline. A longer
11
+ // AbortSignal does not extend it: use Node's HTTP client with one explicit deadline.
12
+ export async function relayLocalRequest(target, method, headers, body, timeoutMs = relayRequestTimeoutMs(target.pathname)) {
13
+ return await new Promise((resolve, reject) => {
14
+ const request = (target.protocol === "https:" ? https : http).request(target, {
15
+ method, headers: Object.fromEntries(headers.entries()),
16
+ }, (response) => {
17
+ const chunks = [];
18
+ response.on("data", (chunk) => chunks.push(chunk));
19
+ response.on("error", reject);
20
+ response.on("end", () => resolve({
21
+ status: response.statusCode || 502,
22
+ contentType: String(response.headers["content-type"] || "application/octet-stream"),
23
+ bytes: Buffer.concat(chunks),
24
+ }));
25
+ response.on("close", () => clearTimeout(timer));
26
+ });
27
+ const timer = setTimeout(() => request.destroy(new Error("本机处理等待超时;任务可能仍在运行,请先查看状态,勿重复提交。")), timeoutMs);
28
+ timer.unref();
29
+ request.on("error", reject);
30
+ request.on("close", () => clearTimeout(timer));
31
+ request.end(body);
32
+ });
33
+ }
@@ -5,9 +5,10 @@ import express from "express";
5
5
  import { runClaudeTurn } from "../agent/claude.js";
6
6
  import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
7
7
  import { CanvasSession } from "../canvas/session.js";
8
- import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, VERSION } from "../config.js";
8
+ import { CONFIG_DIR, DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, VERSION } from "../config.js";
9
9
  import { FastMossIntegration } from "../integrations/fastmoss.js";
10
10
  import { LocalVideoIntelligence } from "../video-intelligence/local-analysis.js";
11
+ import { VideoAnalysisJobs } from "../video-intelligence/jobs.js";
11
12
  import { startRelayBridge } from "../relay-bridge.js";
12
13
  import { logger } from "../utils/logger.js";
13
14
  import { windowsRootExecutable, windowsSystemExecutable } from "../utils/windows.js";
@@ -35,6 +36,7 @@ export function startHttpServer() {
35
36
  const workflows = new WorkflowManager(config, emit);
36
37
  const fastmoss = new FastMossIntegration();
37
38
  const videoIntelligence = new LocalVideoIntelligence(fastmoss);
39
+ const videoAnalysisJobs = new VideoAnalysisJobs(path.join(CONFIG_DIR, "video-analysis-jobs"), (source, onStage) => videoIntelligence.analyze(source, { onStage, timeoutMs: 30 * 60_000 }));
38
40
  let relayStatus = { ready: false };
39
41
  const app = express();
40
42
  app.disable("x-powered-by");
@@ -59,7 +61,7 @@ export function startHttpServer() {
59
61
  return void res.json({});
60
62
  next();
61
63
  });
62
- app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, workflow: workflows.health(), relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
64
+ app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, workflow: workflows.health(), videoAnalysisJobs: { version: 1, active: videoAnalysisJobs.activeCount }, relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
63
65
  app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
64
66
  app.use((req, res, next) => {
65
67
  if (validToken(req, requestUrl(req, config), config.token))
@@ -145,6 +147,13 @@ export function startHttpServer() {
145
147
  app.post("/agent/integrations/fastmoss/video-learning/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.captureLearningVideos(req.body || {}) })));
146
148
  app.post("/agent/video-intelligence/archive", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.archive(req.body?.source || {}, req.body?.upload || {}) })));
147
149
  app.post("/agent/video-intelligence/analyze", route(async (req, res) => res.json({ ok: true, ...await videoIntelligence.analyze(req.body?.source || req.body || {}) })));
150
+ app.post("/agent/video-intelligence/jobs", route(async (req, res) => res.json({ ok: true, job: await videoAnalysisJobs.start(req.body?.source || {}, String(req.body?.requestId || "")) })));
151
+ app.get("/agent/video-intelligence/jobs/:id", route(async (req, res) => {
152
+ const job = await videoAnalysisJobs.get(routeParam(req.params.id));
153
+ if (!job)
154
+ return res.status(404).json({ ok: false, error: "本机视频分析任务不存在" });
155
+ return res.json({ ok: true, job });
156
+ }));
148
157
  app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
149
158
  app.get("/agent/codex/workspace", (_req, res) => {
150
159
  const workspace = ensureSiteWorkspace(config);
@@ -0,0 +1,32 @@
1
+ import { type LocalVideoLearningSource } from "./local-analysis.js";
2
+ type Result = {
3
+ analysis: Record<string, unknown>;
4
+ evidence: Record<string, unknown>;
5
+ };
6
+ type Job = {
7
+ id: string;
8
+ attemptId: string;
9
+ requestId: string;
10
+ status: "running" | "completed" | "failed";
11
+ stage: string;
12
+ updatedAt: string;
13
+ result?: Result;
14
+ error?: string;
15
+ };
16
+ type Analyze = (source: LocalVideoLearningSource, stage: (value: string) => Promise<void>) => Promise<Result>;
17
+ export declare class VideoAnalysisJobs {
18
+ private directory;
19
+ private analyze;
20
+ private active;
21
+ private locks;
22
+ constructor(directory: string, analyze: Analyze);
23
+ get activeCount(): number;
24
+ start(source: LocalVideoLearningSource & {
25
+ archived_sha256?: string;
26
+ archivedSha256?: string;
27
+ }, requestId: string): Promise<Job>;
28
+ get(id: string): Promise<Job | null>;
29
+ private run;
30
+ private save;
31
+ }
32
+ export {};
@@ -0,0 +1,89 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { localVideoAnalysisPrompt } from "./local-analysis.js";
5
+ export class VideoAnalysisJobs {
6
+ directory;
7
+ analyze;
8
+ active = new Map();
9
+ locks = new Map();
10
+ constructor(directory, analyze) {
11
+ this.directory = directory;
12
+ this.analyze = analyze;
13
+ }
14
+ get activeCount() { return this.active.size; }
15
+ async start(source, requestId) {
16
+ if (!requestId || requestId.length > 100)
17
+ throw new Error("视频分析请求标识无效");
18
+ const identity = source.archived_sha256 || source.archivedSha256 || source.sourceUrl || source.source_url;
19
+ if (!source.id || !identity)
20
+ throw new Error("视频分析缺少稳定原片身份,请刷新后重试");
21
+ const id = createHash("sha256").update(JSON.stringify([source.id, identity, localVideoAnalysisPrompt(source, 0, "", [])])).digest("hex");
22
+ // Serialize admission for the same source, including network replay.
23
+ const previous = this.locks.get(id) || Promise.resolve();
24
+ const admission = previous.catch(() => undefined).then(async () => {
25
+ const existing = await this.get(id);
26
+ if (existing && (existing.status === "completed" || this.active.has(id) || existing.requestId === requestId))
27
+ return existing;
28
+ const job = { id, attemptId: randomUUID(), requestId, status: "running", stage: "读取原片与抽帧", updatedAt: new Date().toISOString() };
29
+ await this.save(job);
30
+ const task = this.run(job, source).catch(() => undefined).finally(() => this.active.delete(id));
31
+ this.active.set(id, task);
32
+ return { ...job };
33
+ });
34
+ this.locks.set(id, admission);
35
+ try {
36
+ return await admission;
37
+ }
38
+ finally {
39
+ if (this.locks.get(id) === admission)
40
+ this.locks.delete(id);
41
+ }
42
+ }
43
+ async get(id) {
44
+ if (!/^[a-f0-9]{64}$/.test(id))
45
+ throw new Error("视频分析任务标识无效");
46
+ let job;
47
+ try {
48
+ job = JSON.parse(await readFile(path.join(this.directory, id + ".json"), "utf8"));
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return null;
53
+ throw error;
54
+ }
55
+ if (job.status === "running" && !this.active.has(id)) {
56
+ job = { ...job, status: "failed", error: "本机程序曾中断,原视频保留;点击重试可重新分析。", stage: "等待手动重试" };
57
+ await this.save(job);
58
+ }
59
+ return job;
60
+ }
61
+ async run(job, source) {
62
+ try {
63
+ const result = await this.analyze(source, async (stage) => {
64
+ job.stage = stage;
65
+ await this.save(job);
66
+ });
67
+ // Persist before the browser can import the result. A lost response
68
+ // or refreshed page reads the same output without another model call.
69
+ job.result = result;
70
+ job.status = "completed";
71
+ job.stage = "分析已保存,等待网页回传";
72
+ }
73
+ catch (error) {
74
+ job.status = "failed";
75
+ const detail = error instanceof Error ? error.message : "本机视频分析失败";
76
+ job.error = detail === "fetch failed" ? `${job.stage}时网络连接失败;原片保留,请恢复网络后重试。` : detail;
77
+ job.stage = "分析失败,可手动重试";
78
+ }
79
+ await this.save(job);
80
+ }
81
+ async save(job) {
82
+ job.updatedAt = new Date().toISOString();
83
+ await mkdir(this.directory, { recursive: true });
84
+ const file = path.join(this.directory, job.id + ".json");
85
+ const temporary = file + "." + randomUUID() + ".tmp";
86
+ await writeFile(temporary, JSON.stringify(job), { mode: 0o600 });
87
+ await rename(temporary, file);
88
+ }
89
+ }
@@ -53,7 +53,10 @@ export declare class LocalVideoIntelligence {
53
53
  durationMs: number;
54
54
  };
55
55
  }>;
56
- analyze(source: LocalVideoLearningSource): Promise<{
56
+ analyze(source: LocalVideoLearningSource, options?: {
57
+ onStage?: (stage: string) => Promise<void>;
58
+ timeoutMs?: number;
59
+ }): Promise<{
57
60
  analysis: Record<string, unknown>;
58
61
  evidence: {
59
62
  durationMs: number;
@@ -60,7 +60,7 @@ export class LocalVideoIntelligence {
60
60
  await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
61
61
  }
62
62
  }
63
- async analyze(source) {
63
+ async analyze(source, options) {
64
64
  const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
65
65
  const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
66
66
  const hasArchivedMedia = Boolean(archivedVideoUrl);
@@ -74,6 +74,7 @@ export class LocalVideoIntelligence {
74
74
  let transcript = archivedTranscript;
75
75
  let transcriptSource = archivedTranscript ? "website-archive-caption" : "";
76
76
  if (!transcript && hasArchivedMedia) {
77
+ await options?.onStage?.("识别原片口播");
77
78
  transcript = await transcribeLocalMedia("videoFile" in visualEvidence && typeof visualEvidence.videoFile === "string" ? visualEvidence.videoFile : undefined, workDir);
78
79
  transcriptSource = transcript ? `local-asr:${LOCAL_ASR_MODEL}` : "local-asr:no-speech-detected";
79
80
  }
@@ -85,7 +86,8 @@ export class LocalVideoIntelligence {
85
86
  throw new Error("没有取得任何真实视频画面,已停止分析");
86
87
  const attachments = await materializeFrames(visualEvidence.frames, workDir);
87
88
  const prompt = localVideoAnalysisPrompt(source, visualEvidence.durationMs, transcript, attachments);
88
- const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir);
89
+ await options?.onStage?.("Codex 正在对齐画面与口播,生成详细蓝图");
90
+ const analysis = await runLocalCodexAnalysis(prompt, attachments, workDir, options?.timeoutMs);
89
91
  assertJointVisualAndSpokenEvidence(analysis);
90
92
  return {
91
93
  analysis,
@@ -485,7 +487,7 @@ function transcriptPreference(file) {
485
487
  return 3;
486
488
  return 1;
487
489
  }
488
- async function runLocalCodexAnalysis(prompt, attachments, workDir) {
490
+ async function runLocalCodexAnalysis(prompt, attachments, workDir, timeoutMs = ANALYSIS_TIMEOUT_MS) {
489
491
  const outputFile = path.join(workDir, "analysis.json");
490
492
  // npm hoists dependencies next to the installed package in production, while
491
493
  // local development may keep them at a different ancestor. Resolve from this
@@ -495,7 +497,7 @@ async function runLocalCodexAnalysis(prompt, attachments, workDir) {
495
497
  for (const attachment of attachments)
496
498
  args.push("--image", path.join(workDir, String(attachment.name)));
497
499
  args.push("-");
498
- const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs: ANALYSIS_TIMEOUT_MS, stdin: prompt });
500
+ const result = await runProcess(process.execPath, args, { cwd: workDir, timeoutMs, stdin: prompt });
499
501
  if (!result.ok)
500
502
  throw new Error(localCodexAnalysisError(result));
501
503
  const raw = (await readFile(outputFile, "utf8")).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.81",
3
+ "version": "0.4.82",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",