@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.
- package/dist/relay-bridge.js +8 -5
- package/dist/relay-request.d.ts +6 -0
- package/dist/relay-request.js +33 -0
- package/dist/server/http.js +11 -2
- package/dist/video-intelligence/jobs.d.ts +32 -0
- package/dist/video-intelligence/jobs.js +89 -0
- package/dist/video-intelligence/local-analysis.d.ts +4 -1
- package/dist/video-intelligence/local-analysis.js +6 -4
- package/package.json +1 -1
package/dist/relay-bridge.js
CHANGED
|
@@ -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
|
|
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.
|
|
88
|
-
bodyBase64: bytes.toString("base64"),
|
|
87
|
+
contentType: response.contentType,
|
|
88
|
+
bodyBase64: response.bytes.toString("base64"),
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
|
-
|
|
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
|
+
}
|
package/dist/server/http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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, "");
|