@xiaohhhh1/canvas-agent 0.4.42 → 0.4.43

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,3 +1,4 @@
1
+ import { type JsonRecord } from "../utils/value.js";
1
2
  import type { CodexPlanUpdate, CodexRequestParams } from "./codex-protocol.js";
2
3
  import type { AgentEmit, AgentPermissionMode } from "./types.js";
3
4
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
@@ -13,6 +14,7 @@ export declare class CodexAppClient {
13
14
  private pending;
14
15
  private activeTurns;
15
16
  private completedTurns;
17
+ private finalTextByTurn;
16
18
  private pendingDeltas;
17
19
  private plansByTurn;
18
20
  private approvalRequests;
@@ -41,7 +43,7 @@ export declare class CodexAppClient {
41
43
  /** 清理已归档线程的任务计划缓存。 */
42
44
  clearPlanUpdates(threadId: string): void;
43
45
  /** 启动一个 Codex turn 并等待完成通知。 */
44
- startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void): Promise<void>;
46
+ startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void, outputSchema?: JsonRecord): Promise<string>;
45
47
  /** 中断当前正在运行的 Codex turn。 */
46
48
  interruptCurrentTurn(): Promise<boolean>;
47
49
  /** 回复网页端已经确认的 Codex 权限请求。 */
@@ -21,6 +21,7 @@ export class CodexAppClient {
21
21
  pending = new Map();
22
22
  activeTurns = new Map();
23
23
  completedTurns = new Map();
24
+ finalTextByTurn = new Map();
24
25
  pendingDeltas = new Map();
25
26
  plansByTurn = new Map();
26
27
  approvalRequests = new Map();
@@ -92,9 +93,9 @@ export class CodexAppClient {
92
93
  });
93
94
  }
94
95
  /** 启动一个 Codex turn 并等待完成通知。 */
95
- async startTurn(threadId, prompt, images, permissionMode, onTurn) {
96
+ async startTurn(threadId, prompt, images, permissionMode, onTurn, outputSchema) {
96
97
  this.currentThreadId = threadId;
97
- const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
98
+ const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(outputSchema ? { outputSchema } : {}) });
98
99
  const turnId = turn.id;
99
100
  if (!turnId)
100
101
  throw new Error("Codex app-server 没有返回 turn id");
@@ -105,11 +106,11 @@ export class CodexAppClient {
105
106
  this.completedTurns.delete(turnId);
106
107
  this.currentThreadId = "";
107
108
  this.currentTurnId = "";
108
- if (completed)
109
- throw completed;
110
- return;
109
+ if (completed?.error)
110
+ throw completed.error;
111
+ return completed?.text || "";
111
112
  }
112
- await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
113
+ return await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
113
114
  }
114
115
  /** 中断当前正在运行的 Codex turn。 */
115
116
  async interruptCurrentTurn() {
@@ -235,6 +236,9 @@ export class CodexAppClient {
235
236
  const streamedText = this.textByItem.get(id);
236
237
  if (item?.type === "agent_message" && streamedText && !item.text)
237
238
  item.text = streamedText;
239
+ const turnId = String(field(params, "turnId") || "");
240
+ if (turnId && item?.type === "agent_message" && item.text)
241
+ this.finalTextByTurn.set(turnId, String(item.text));
238
242
  if (id)
239
243
  this.textByItem.delete(id);
240
244
  }
@@ -253,12 +257,14 @@ export class CodexAppClient {
253
257
  const turnId = turn.id;
254
258
  const pending = this.activeTurns.get(turnId);
255
259
  const error = turn.error;
260
+ const text = this.finalTextByTurn.get(turnId) || "";
261
+ this.finalTextByTurn.delete(turnId);
256
262
  if (pending) {
257
263
  this.activeTurns.delete(turnId);
258
- error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
264
+ error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(text);
259
265
  }
260
266
  else if (turnId) {
261
- this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
267
+ this.completedTurns.set(turnId, { error: error ? new Error(error.message || "Codex turn failed") : null, text });
262
268
  }
263
269
  if (turnId === this.currentTurnId) {
264
270
  this.currentThreadId = "";
@@ -328,6 +334,7 @@ export class CodexAppClient {
328
334
  this.activeTurns.clear();
329
335
  this.pendingDeltas.clear();
330
336
  this.textByItem.clear();
337
+ this.finalTextByTurn.clear();
331
338
  this.approvalRequests.clear();
332
339
  this.currentThreadId = "";
333
340
  this.currentTurnId = "";
@@ -114,6 +114,7 @@ type CodexRequestSpec = {
114
114
  } | {
115
115
  type: "dangerFullAccess";
116
116
  };
117
+ outputSchema?: JsonRecord;
117
118
  };
118
119
  result: {
119
120
  turn: CodexTurn;
@@ -4,6 +4,7 @@ type CodexRunOptions = {
4
4
  cwd?: string;
5
5
  permissionMode?: AgentPermissionMode;
6
6
  appEmit?: AgentEmit;
7
+ outputSchema?: Record<string, unknown>;
7
8
  onStart?: () => void;
8
9
  onThread?: (threadId: string) => void;
9
10
  onTurn?: (turnId: string) => void;
@@ -11,6 +12,7 @@ type CodexRunOptions = {
11
12
  };
12
13
  export type CodexRunResult = {
13
14
  ok: true;
15
+ text: string;
14
16
  } | {
15
17
  ok: false;
16
18
  error: string;
@@ -103,7 +103,8 @@ async function runCodexTurnNow(prompt, emit, attachments, options) {
103
103
  options.onThread?.(threadId);
104
104
  unmaterializedThreadIds.delete(threadId);
105
105
  try {
106
- await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
106
+ const text = await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn, options.outputSchema);
107
+ return { ok: true, text };
107
108
  }
108
109
  catch (error) {
109
110
  if (!isRecoverableThreadError(error))
@@ -113,9 +114,9 @@ async function runCodexTurnNow(prompt, emit, attachments, options) {
113
114
  threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
114
115
  options.onThread?.(threadId);
115
116
  unmaterializedThreadIds.delete(threadId);
116
- await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
117
+ const text = await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn, options.outputSchema);
118
+ return { ok: true, text };
117
119
  }
118
- return { ok: true };
119
120
  }
120
121
  catch (error) {
121
122
  logger.error("Codex turn failed", error);
@@ -10,6 +10,7 @@ import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { windowsPowerShellExecutable } from "../utils/windows.js";
12
12
  import { FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_SCRIPT_CHUNK_SIZES } from "./constants.js";
13
+ import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
13
14
  const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
14
15
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
15
16
  export class WorkflowManager {
@@ -235,14 +236,24 @@ export class WorkflowManager {
235
236
  record.message = `本机 Codex 正在写第 ${missing[0]}–${missing.at(-1)} 条(总计 ${task.requested_count} 条)`;
236
237
  record.updatedAt = now();
237
238
  this.save();
238
- const result = await runCodexTurn(scriptChunkPrompt(id, task, missing), this.emit, [], { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", onThread: (threadId) => { record.threadId = threadId; this.save(); } });
239
+ const result = await runCodexTurn(scriptChunkPrompt(id, task, missing), this.emit, [], { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", outputSchema: flowCScriptOutputSchema(Number(task.duration_seconds || 10), missing.length), onThread: (threadId) => { record.threadId = threadId; this.save(); } });
239
240
  await this.scriptTask(id);
240
241
  progressed = record.receivedOrdinals.length > before;
242
+ if (!progressed && result.ok && result.text) {
243
+ try {
244
+ await this.submitScriptChunk(id, parseFlowCScriptOutput(result.text, missing));
245
+ await this.scriptTask(id);
246
+ progressed = record.receivedOrdinals.length > before;
247
+ }
248
+ catch (error) {
249
+ lastError = error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验";
250
+ }
251
+ }
241
252
  if (progressed)
242
253
  break;
243
254
  // Keep provider/client details out of the browser-facing state. The
244
255
  // underlying Codex runner already records the technical error locally.
245
- lastError = result.ok ? "Codex 未调用回传工具" : "Codex 本轮执行失败";
256
+ lastError ||= result.ok ? "Codex 未返回可用的结构化脚本" : "Codex 本轮执行失败";
246
257
  if (attemptIndex < FLOW_C_SCRIPT_CHUNK_SIZES.length - 1) {
247
258
  const nextSize = FLOW_C_SCRIPT_CHUNK_SIZES[attemptIndex + 1];
248
259
  record.message = `第 ${lastRange} 条未成功回传,正在换新会话并缩小为每段 ${nextSize} 条重试`;
@@ -415,13 +426,17 @@ function scriptChunkPrompt(id, task, ordinals) {
415
426
  每条回传同时填写 script=masterScript、masterScript 和 segments。segments[0].continuityMode 必须是 reset;同人物同场景延续时后续段用 continue,明确换人物或换场景才用 reset。continue 段必须复述并固定人物年龄、外貌、服装、场景陈设、光线、机位基调和商品当前状态。
416
427
  每份 masterScript 和每个 segment 都要写同一套详细音色身份:性别、年龄段、音高、音质、语速、口音、停连和说话习惯;情绪随剧情逐段变化,但音色身份不变。仍按短视频快节奏口播,不因时长增加而拖慢。`;
417
428
  return `你正在后台处理“抖音小辉跨境工具”Flow C 脚本交接 ${id}。
418
- 必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
429
+ 完整中心任务已经附在本提示词末尾。只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
419
430
  本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
420
431
  脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${longVideoRules}
421
432
  每一个 ordinal 都必须从头到尾创作一份完整、独立的原生带货脚本,画面和口播必须在同一份脚本中一起独立构思。先让它成为一个当地真人在具体生活情境里自然发现、吐槽、试用或验证商品的短内容,再完成带货;禁止写成品牌广告片、棚拍宣传片、电视购物、逐条念卖点或全程完美对镜讲解。独立脚本天然包含独立口播:不得把任何一份口播当作整批公共模板,不得复用完整台词,也不得只换人物、场景或少数词后保留近似口播。音色身份可以为同一人物保持一致,但每条的 Hook、人物行动、证明表达和 CTA 都必须重新写。
422
433
  用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
423
434
  工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
424
- 写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
435
+ 只返回符合当前结构化输出契约的 jobs;每条必须包含 ordinal、productIndex、sellingFormId script,长视频还必须包含 masterScript 与完整 segments。无需调用 MCP,Agent 会在本机校验后自动回传。不要创建付费批次,不要调用供应商模型。
436
+
437
+ 【中心任务完整 instructions】
438
+ ${task.instructions}
439
+ 【中心任务 instructions 结束】`;
425
440
  }
426
441
  function scriptProductAssignments(productQuantities, ordinals) {
427
442
  const assignments = [];
@@ -0,0 +1,5 @@
1
+ type JsonSchema = Record<string, unknown>;
2
+ /** Codex app-server 的结构化脚本返回契约;回传由 Agent 完成,不依赖模型调用 MCP。 */
3
+ export declare function flowCScriptOutputSchema(durationSeconds: 10 | 20 | 30, count: number): JsonSchema;
4
+ export declare function parseFlowCScriptOutput(value: string, expectedOrdinals: number[]): unknown[];
5
+ export {};
@@ -0,0 +1,52 @@
1
+ const text = { type: "string", minLength: 1 };
2
+ const continuityFields = ["character", "wardrobe", "location", "lighting", "productState", "unfinishedAction", "nextGoal"];
3
+ function object(properties, required = Object.keys(properties)) {
4
+ return { type: "object", properties, required, additionalProperties: false };
5
+ }
6
+ function continuitySchema(frameField) {
7
+ return object(Object.fromEntries([...continuityFields, frameField].map((field) => [field, text])));
8
+ }
9
+ function segmentSchema() {
10
+ return object({
11
+ continuityMode: { type: "string", enum: ["reset", "continue"] },
12
+ continuity: continuitySchema("previousEndingFrame"),
13
+ endingState: continuitySchema("endingFrame"),
14
+ shots: {
15
+ type: "array",
16
+ minItems: 1,
17
+ maxItems: 8,
18
+ items: object({
19
+ startSeconds: { type: "number", minimum: 0, maximum: 10 },
20
+ endSeconds: { type: "number", minimum: 0, maximum: 10 },
21
+ visual: text,
22
+ voiceover: text,
23
+ onScreenText: text,
24
+ evidence: text,
25
+ }),
26
+ },
27
+ });
28
+ }
29
+ /** Codex app-server 的结构化脚本返回契约;回传由 Agent 完成,不依赖模型调用 MCP。 */
30
+ export function flowCScriptOutputSchema(durationSeconds, count) {
31
+ const properties = {
32
+ ordinal: { type: "integer", minimum: 1 },
33
+ productIndex: { type: "integer", minimum: 0 },
34
+ sellingFormId: text,
35
+ script: { type: "string", minLength: 40 },
36
+ };
37
+ if (durationSeconds !== 10) {
38
+ properties.masterScript = { type: "string", minLength: 40 };
39
+ properties.segments = { type: "array", minItems: durationSeconds / 10, maxItems: durationSeconds / 10, items: segmentSchema() };
40
+ }
41
+ return object({ jobs: { type: "array", minItems: count, maxItems: count, items: object(properties) } });
42
+ }
43
+ export function parseFlowCScriptOutput(value, expectedOrdinals) {
44
+ const source = String(value || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
45
+ const parsed = JSON.parse(source);
46
+ if (!Array.isArray(parsed.jobs) || parsed.jobs.length !== expectedOrdinals.length)
47
+ throw new Error("Codex 返回的脚本数量不正确");
48
+ const actual = parsed.jobs.map((job) => Number(job?.ordinal));
49
+ if (actual.some((ordinal, index) => ordinal !== expectedOrdinals[index]))
50
+ throw new Error("Codex 返回的 ordinal 与当前分段不一致");
51
+ return parsed.jobs;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.42",
3
+ "version": "0.4.43",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",