@xiaohhhh1/canvas-agent 0.4.10 → 0.4.12

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.
@@ -17,6 +17,7 @@
17
17
  - 用户要求打开带货任务时,调用 `open_flow_c_website`,不要输出或询问 Local URL、连接令牌。
18
18
  - 网站把脚本任务交给本机 Codex 后,使用 `flow_c_get_script_task` 读取完整、服务器持久化的任务,再用 `flow_c_submit_script_chunk` 每次回传 1–30 条。不要要求用户复制 JSON、选择交接文件或把短期令牌发到聊天中。
19
19
  - 大批量脚本按本机助手指定的序号分段创作;批量大小不能成为降低质量、缩短脚本或套用模板的理由。每条都必须独立构思、符合目标市场自然语言习惯、保持紧凑但清晰可说完的 10 秒节奏。
20
+ - 10 秒仍走原有单段结构。只有任务明确给出 20 或 30 秒时,才先写一份完整总脚本,再拆成 2 或 3 个各自从 0–10 秒计时、可独立生成的段落;同人物同场景用 `continue` 并固定人物、服装、音色身份、场景、光线与商品状态,明确换人物或场景才用 `reset`。音色身份需详细到年龄、音高、音质、语速、口音、停连和说话习惯,情绪随剧情变化,整体仍保持快节奏。
20
21
  - 写脚本和回传草案不创建付费任务。只有客户在网站审阅并确认费用后,中心服务才可开始故事板和视频生成。
21
22
 
22
23
  - 用户明确选择流程 C 时,必须新建独立画布、清单和归档目录,不得混用流程 A 或 B 的资产。
@@ -2,6 +2,8 @@ import WebSocket from "ws";
2
2
  const DEFAULT_RELAY_URL = "wss://canvas.xiaohhhh1.com/api/agent-relay";
3
3
  const RECONNECT_DELAY_MS = 3_000;
4
4
  const HEARTBEAT_INTERVAL_MS = 15_000;
5
+ const READY_TIMEOUT_MS = 12_000;
6
+ const LIVENESS_TIMEOUT_MS = 45_000;
5
7
  /**
6
8
  * Keeps an outbound, encrypted connection to the production relay. The canvas
7
9
  * browser can then use same-origin requests instead of directly reaching a
@@ -12,11 +14,35 @@ export function startRelayBridge(config) {
12
14
  const subscriptions = new Map();
13
15
  let socket = null;
14
16
  let stopped = false;
17
+ let relayReady = false;
18
+ let lastHeartbeatAck = 0;
15
19
  let reconnectTimer = null;
16
20
  let heartbeatTimer = null;
21
+ let readyTimer = null;
17
22
  const send = (message) => {
18
- if (socket?.readyState === WebSocket.OPEN)
19
- socket.send(JSON.stringify(message));
23
+ const current = socket;
24
+ if (!relayReady || current?.readyState !== WebSocket.OPEN)
25
+ return false;
26
+ try {
27
+ current.send(JSON.stringify(message));
28
+ return true;
29
+ }
30
+ catch {
31
+ current.terminate();
32
+ return false;
33
+ }
34
+ };
35
+ const clearConnectionTimers = () => {
36
+ if (heartbeatTimer)
37
+ clearInterval(heartbeatTimer);
38
+ if (readyTimer)
39
+ clearTimeout(readyTimer);
40
+ heartbeatTimer = null;
41
+ readyTimer = null;
42
+ };
43
+ const abortSubscriptions = () => {
44
+ subscriptions.forEach((controller) => controller.abort());
45
+ subscriptions.clear();
20
46
  };
21
47
  const stopSubscription = (clientId) => {
22
48
  subscriptions.get(clientId)?.abort();
@@ -67,31 +93,83 @@ export function startRelayBridge(config) {
67
93
  // Ignore malformed relay messages. The relay never receives a secret in an error response.
68
94
  }
69
95
  };
96
+ const scheduleReconnect = () => {
97
+ if (stopped || reconnectTimer)
98
+ return;
99
+ reconnectTimer = setTimeout(() => {
100
+ reconnectTimer = null;
101
+ connect();
102
+ }, RECONNECT_DELAY_MS);
103
+ };
70
104
  const connect = () => {
71
- if (stopped)
105
+ if (stopped || socket)
72
106
  return;
73
107
  try {
74
- socket = new WebSocket(relayUrl);
75
- socket.on("open", () => {
76
- send({ type: "hello", role: "agent", token: config.token });
77
- if (heartbeatTimer)
78
- clearInterval(heartbeatTimer);
79
- heartbeatTimer = setInterval(() => send({ type: "heartbeat", time: Date.now() }), HEARTBEAT_INTERVAL_MS);
108
+ const current = new WebSocket(relayUrl);
109
+ socket = current;
110
+ let disconnected = false;
111
+ const disconnect = () => {
112
+ if (disconnected)
113
+ return;
114
+ disconnected = true;
115
+ if (socket === current)
116
+ socket = null;
117
+ relayReady = false;
118
+ clearConnectionTimers();
119
+ abortSubscriptions();
120
+ scheduleReconnect();
121
+ };
122
+ current.on("open", () => {
123
+ try {
124
+ current.send(JSON.stringify({ type: "hello", role: "agent", token: config.token }));
125
+ }
126
+ catch {
127
+ current.terminate();
128
+ return;
129
+ }
130
+ readyTimer = setTimeout(() => current.terminate(), READY_TIMEOUT_MS);
80
131
  });
81
- socket.on("message", onMessage);
82
- socket.on("close", () => {
83
- if (heartbeatTimer)
84
- clearInterval(heartbeatTimer);
85
- heartbeatTimer = null;
86
- subscriptions.forEach((controller) => controller.abort());
87
- subscriptions.clear();
88
- if (!stopped)
89
- reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
132
+ current.on("message", (raw) => {
133
+ try {
134
+ const message = JSON.parse(raw.toString());
135
+ if (message.type === "ready") {
136
+ relayReady = true;
137
+ lastHeartbeatAck = Date.now();
138
+ if (readyTimer)
139
+ clearTimeout(readyTimer);
140
+ readyTimer = null;
141
+ if (heartbeatTimer)
142
+ clearInterval(heartbeatTimer);
143
+ heartbeatTimer = setInterval(() => {
144
+ if (Date.now() - lastHeartbeatAck > LIVENESS_TIMEOUT_MS) {
145
+ current.terminate();
146
+ return;
147
+ }
148
+ send({ type: "heartbeat", time: Date.now() });
149
+ }, HEARTBEAT_INTERVAL_MS);
150
+ heartbeatTimer.unref();
151
+ return;
152
+ }
153
+ if (message.type === "heartbeat_ack") {
154
+ lastHeartbeatAck = Date.now();
155
+ return;
156
+ }
157
+ if (!relayReady)
158
+ return;
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ onMessage(raw);
90
164
  });
91
- socket.on("error", () => undefined);
165
+ current.on("close", disconnect);
166
+ current.on("error", () => current.terminate());
92
167
  }
93
168
  catch {
94
- reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
169
+ socket = null;
170
+ relayReady = false;
171
+ clearConnectionTimers();
172
+ scheduleReconnect();
95
173
  }
96
174
  };
97
175
  connect();
@@ -99,11 +177,10 @@ export function startRelayBridge(config) {
99
177
  stopped = true;
100
178
  if (reconnectTimer)
101
179
  clearTimeout(reconnectTimer);
102
- if (heartbeatTimer)
103
- clearInterval(heartbeatTimer);
104
- subscriptions.forEach((controller) => controller.abort());
105
- subscriptions.clear();
180
+ clearConnectionTimers();
181
+ abortSubscriptions();
106
182
  socket?.close();
183
+ socket = null;
107
184
  };
108
185
  }
109
186
  async function pipeEvents(clientId, config, signal, send) {
@@ -31,6 +31,11 @@ function registerWorkflowTools(server, config) {
31
31
  productIndex: z.number().int().nonnegative(),
32
32
  sellingFormId: z.string().min(1).max(100),
33
33
  script: z.string().min(40).max(20_000),
34
+ masterScript: z.string().min(40).max(20_000).optional(),
35
+ segments: z.array(z.object({
36
+ script: z.string().min(40).max(20_000),
37
+ continuityMode: z.enum(["continue", "reset"]),
38
+ })).min(2).max(3).optional(),
34
39
  })).min(1).max(FLOW_C_SCRIPT_CHUNK_MAX),
35
40
  },
36
41
  }, async ({ handoffId, jobs }) => workflowTool(config, `/agent/workflow/script-handoffs/${encodeURIComponent(handoffId)}/draft-chunks`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jobs }) }));
@@ -6,6 +6,7 @@ type ScriptTask = {
6
6
  id: string;
7
7
  workflow: "flow-c";
8
8
  market: string;
9
+ duration_seconds?: 10 | 20 | 30;
9
10
  requested_count: number;
10
11
  product_quantities: number[];
11
12
  instructions: string;
@@ -386,10 +386,15 @@ export class WorkflowManager {
386
386
  class ExpiredCapabilityError extends Error {
387
387
  }
388
388
  function scriptChunkPrompt(id, task, ordinals) {
389
+ const duration = Number(task.duration_seconds || 10);
390
+ const longVideoRules = duration === 10 ? "" : `
391
+ 本任务每条成片为 ${duration} 秒。先创作一份完整连贯的 masterScript,再严格拆成 ${duration / 10} 个可以独立交给视频模型的 10 秒 segments;每一段内部时间都从 0–10 秒重新写,绝不能引用“上一条视频”或写 10–20、20–30 秒这种模型无法理解的时间。
392
+ 每条回传同时填写 script=masterScript、masterScript 和 segments。segments[0].continuityMode 必须是 reset;同人物同场景延续时后续段用 continue,明确换人物或换场景才用 reset。continue 段必须复述并固定人物年龄、外貌、服装、场景陈设、光线、机位基调和商品当前状态。
393
+ 每份 masterScript 和每个 segment 都要写同一套详细音色身份:性别、年龄段、音高、音质、语速、口音、停连和说话习惯;情绪随剧情逐段变化,但音色身份不变。仍按短视频快节奏口播,不因时长增加而拖慢。`;
389
394
  return `你正在后台处理“抖音小辉跨境工具”Flow C 脚本交接 ${id}。
390
395
  必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
391
396
  本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
392
- 脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的 10 秒节奏。
397
+ 脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${longVideoRules}
393
398
  用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
394
399
  工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
395
400
  写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",