@xiaohhhh1/canvas-agent 0.4.48 → 0.4.50

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,6 +1,10 @@
1
1
  import { type JsonRecord } from "../utils/value.js";
2
2
  import type { CodexPlanUpdate, CodexRequestParams, CodexTurn } from "./codex-protocol.js";
3
3
  import type { AgentEmit, AgentPermissionMode } from "./types.js";
4
+ export type CodexModelSettings = {
5
+ model?: string;
6
+ reasoningEffort?: string;
7
+ };
4
8
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
5
9
  export declare class CodexAppClient {
6
10
  private child;
@@ -23,9 +27,9 @@ export declare class CodexAppClient {
23
27
  /** 启动并初始化 Codex app-server。 */
24
28
  static start(emit: AgentEmit, onExit: () => void): Promise<CodexAppClient>;
25
29
  /** 创建新的 Codex 线程。 */
26
- startThread(cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
30
+ startThread(cwd?: string, permissionMode?: AgentPermissionMode, modelSettings?: CodexModelSettings): Promise<import("./codex-protocol.js").CodexThread>;
27
31
  /** 恢复已有 Codex 线程。 */
28
- resumeThread(threadId: string, cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
32
+ resumeThread(threadId: string, cwd?: string, permissionMode?: AgentPermissionMode, modelSettings?: CodexModelSettings): Promise<import("./codex-protocol.js").CodexThread>;
29
33
  /** 查询 Codex 线程列表。 */
30
34
  listThreads(params: CodexRequestParams<"thread/list">): Promise<{
31
35
  data: import("./codex-protocol.js").CodexThread[];
@@ -43,7 +47,7 @@ export declare class CodexAppClient {
43
47
  /** 清理已归档线程的任务计划缓存。 */
44
48
  clearPlanUpdates(threadId: string): void;
45
49
  /** 启动一个 Codex turn 并等待完成通知。 */
46
- startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void, outputSchema?: JsonRecord): Promise<string>;
50
+ startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void, outputSchema?: JsonRecord, modelSettings?: CodexModelSettings): Promise<string>;
47
51
  /** 中断当前正在运行的 Codex turn。 */
48
52
  interruptCurrentTurn(): Promise<boolean>;
49
53
  /** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
@@ -57,15 +57,15 @@ export class CodexAppClient {
57
57
  return client;
58
58
  }
59
59
  /** 创建新的 Codex 线程。 */
60
- async startThread(cwd, permissionMode = "request") {
61
- const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
60
+ async startThread(cwd, permissionMode = "request", modelSettings = {}) {
61
+ const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}), threadSource: "user" });
62
62
  if (!thread.id)
63
63
  throw new Error("Codex app-server 没有返回 thread id");
64
64
  return thread;
65
65
  }
66
66
  /** 恢复已有 Codex 线程。 */
67
- async resumeThread(threadId, cwd, permissionMode = "request") {
68
- const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
67
+ async resumeThread(threadId, cwd, permissionMode = "request", modelSettings = {}) {
68
+ const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}) });
69
69
  if (!thread.id)
70
70
  throw new Error("Codex app-server 没有返回 thread id");
71
71
  return thread;
@@ -94,9 +94,9 @@ export class CodexAppClient {
94
94
  });
95
95
  }
96
96
  /** 启动一个 Codex turn 并等待完成通知。 */
97
- async startTurn(threadId, prompt, images, permissionMode, onTurn, outputSchema) {
97
+ async startTurn(threadId, prompt, images, permissionMode, onTurn, outputSchema, modelSettings = {}) {
98
98
  this.currentThreadId = threadId;
99
- const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(outputSchema ? { outputSchema } : {}) });
99
+ const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(modelSettings.model ? { model: modelSettings.model } : {}), ...(modelSettings.reasoningEffort ? { effort: modelSettings.reasoningEffort } : {}), ...(outputSchema ? { outputSchema } : {}) });
100
100
  const turnId = turn.id;
101
101
  if (!turnId)
102
102
  throw new Error("Codex app-server 没有返回 turn id");
@@ -445,11 +445,11 @@ function canvasAgentMcpCommand() {
445
445
  return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
446
446
  }
447
447
  /** 生成 Codex app-server 使用的 MCP 配置。 */
448
- function codexConfig(permissionMode) {
449
- return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
448
+ function codexConfig(permissionMode, modelSettings = {}) {
449
+ return { model_reasoning_summary: "auto", ...(modelSettings.reasoningEffort ? { model_reasoning_effort: modelSettings.reasoningEffort } : {}), ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
450
450
  }
451
- function threadSettings(permissionMode) {
452
- return { approvalPolicy: permissionMode === "full" ? "never" : "on-request", sandbox: permissionMode === "full" ? "danger-full-access" : "workspace-write", config: codexConfig(permissionMode) };
451
+ function threadSettings(permissionMode, modelSettings = {}) {
452
+ return { approvalPolicy: permissionMode === "full" ? "never" : "on-request", sandbox: permissionMode === "full" ? "danger-full-access" : "workspace-write", config: codexConfig(permissionMode, modelSettings) };
453
453
  }
454
454
  function turnSettings(permissionMode) {
455
455
  return {
@@ -42,6 +42,7 @@ type ThreadOptions = {
42
42
  sandbox: "workspace-write" | "danger-full-access";
43
43
  config: JsonRecord;
44
44
  cwd?: string;
45
+ model?: string;
45
46
  };
46
47
  type CodexRequestSpec = {
47
48
  initialize: {
@@ -115,6 +116,8 @@ type CodexRequestSpec = {
115
116
  } | {
116
117
  type: "dangerFullAccess";
117
118
  };
119
+ model?: string;
120
+ effort?: string;
118
121
  outputSchema?: JsonRecord;
119
122
  };
120
123
  result: {
@@ -17,11 +17,47 @@ export type CodexRunResult = {
17
17
  ok: false;
18
18
  error: string;
19
19
  };
20
+ export type CodexWorkflowRunResult = {
21
+ ok: true;
22
+ text: string;
23
+ timings: {
24
+ queueWaitMs: number;
25
+ threadStartMs: number;
26
+ modelMs: number;
27
+ };
28
+ } | {
29
+ ok: false;
30
+ error: string;
31
+ retryable: boolean;
32
+ timings: {
33
+ queueWaitMs: number;
34
+ threadStartMs: number;
35
+ modelMs: number;
36
+ };
37
+ };
38
+ export declare const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
39
+ export declare const FLOW_C_CODEX_REASONING_EFFORT = "high";
40
+ export declare const FLOW_C_CODEX_WORKER_CONCURRENCY: number;
41
+ export declare function flowCCodexWorkerStatus(): {
42
+ active: number;
43
+ limit: number;
44
+ };
20
45
  export { summarizeCodexThread } from "./codex-history.js";
21
46
  /** 将 Codex turn 加入串行队列并等待执行完成。 */
22
47
  export declare function runCodexTurn(prompt: string, emit: AgentEmit, attachments?: AgentAttachment[], options?: CodexRunOptions): Promise<CodexRunResult>;
23
48
  /** 中断当前线程正在执行的 Codex turn。 */
24
49
  export declare function interruptCodexTurn(threadId?: string): Promise<boolean>;
50
+ /**
51
+ * Flow C 专用受控并行回合。每个 lane 独占一个 app-server,chunk 使用独立线程,
52
+ * 从而不会被交互式 Codex 全局队列或另一个长脚本回合阻塞。
53
+ */
54
+ export declare function runCodexWorkflowTurn(prompt: string, emit: AgentEmit, options: CodexRunOptions & {
55
+ timeoutMs: number;
56
+ onWorkerStart?: () => void;
57
+ onWorkerFinish?: () => void;
58
+ }): Promise<CodexWorkflowRunResult>;
59
+ /** Contract/preflight 4xx errors are deterministic and must never burn every fallback chunk size. */
60
+ export declare function isDeterministicWorkflowContractError(error: unknown): boolean;
25
61
  /** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
26
62
  export declare function restartCodexApp(message?: string): Promise<void>;
27
63
  /** 回复当前 app-server 的待处理权限请求。 */
@@ -5,11 +5,18 @@ import { logger } from "../utils/logger.js";
5
5
  import { errorMessage, field } from "../utils/value.js";
6
6
  import { CodexAppClient } from "./codex-client.js";
7
7
  import { summarizeCodexThread, threadMessages } from "./codex-history.js";
8
+ import { boundedWorkerConcurrency, WorkerPool } from "./worker-pool.js";
9
+ export const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
10
+ export const FLOW_C_CODEX_REASONING_EFFORT = "high";
11
+ export const FLOW_C_CODEX_WORKER_CONCURRENCY = boundedWorkerConcurrency(process.env.FLOW_C_SCRIPT_CONCURRENCY);
12
+ export function flowCCodexWorkerStatus() { return { active: workflowCodexPool.activeCount, limit: FLOW_C_CODEX_WORKER_CONCURRENCY }; }
8
13
  let codexQueue = Promise.resolve();
9
14
  let codexApp = null;
10
15
  let codexAppStart = null;
11
16
  let codexThreadId = "";
12
17
  const unmaterializedThreadIds = new Set();
18
+ const workflowCodexPool = new WorkerPool(FLOW_C_CODEX_WORKER_CONCURRENCY);
19
+ const workflowCodexApps = new Map();
13
20
  export { summarizeCodexThread } from "./codex-history.js";
14
21
  /** 将 Codex turn 加入串行队列并等待执行完成。 */
15
22
  export async function runCodexTurn(prompt, emit, attachments = [], options = {}) {
@@ -24,6 +31,67 @@ export async function interruptCodexTurn(threadId) {
24
31
  return false;
25
32
  return await codexApp.interruptCurrentTurn();
26
33
  }
34
+ /**
35
+ * Flow C 专用受控并行回合。每个 lane 独占一个 app-server,chunk 使用独立线程,
36
+ * 从而不会被交互式 Codex 全局队列或另一个长脚本回合阻塞。
37
+ */
38
+ export async function runCodexWorkflowTurn(prompt, emit, options) {
39
+ if (!prompt.trim())
40
+ return { ok: false, error: "Codex prompt is empty", retryable: false, timings: { queueWaitMs: 0, threadStartMs: 0, modelMs: 0 } };
41
+ return await workflowCodexPool.run(async (workerIndex, queueWaitMs) => {
42
+ options.onWorkerStart?.();
43
+ const modelSettings = { model: FLOW_C_CODEX_MODEL, reasoningEffort: FLOW_C_CODEX_REASONING_EFFORT };
44
+ const threadStartedAt = Date.now();
45
+ let threadStartMs = 0;
46
+ let modelStartedAt = 0;
47
+ let timer;
48
+ let app = workflowCodexApps.get(workerIndex);
49
+ try {
50
+ if (!app)
51
+ app = await startWorkflowCodexApp(workerIndex, options.appEmit || emit);
52
+ const thread = await app.startThread(options.cwd, options.permissionMode || "request", modelSettings);
53
+ const threadId = String(field(thread, "id") || "");
54
+ options.onThread?.(threadId);
55
+ threadStartMs = Date.now() - threadStartedAt;
56
+ modelStartedAt = Date.now();
57
+ const turn = app.startTurn(threadId, prompt, [], options.permissionMode || "request", options.onTurn, options.outputSchema, modelSettings);
58
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), options.timeoutMs); });
59
+ const result = await Promise.race([turn, timeout]);
60
+ if (result === "timeout") {
61
+ workflowCodexApps.delete(workerIndex);
62
+ await app.terminate("Flow C 脚本回合超时,已仅回收当前 worker");
63
+ await turn.catch(() => undefined);
64
+ return { ok: false, error: "Flow C 脚本回合超过 8 分钟,已自动终止当前 worker", retryable: true, timings: { queueWaitMs, threadStartMs, modelMs: Date.now() - modelStartedAt } };
65
+ }
66
+ return { ok: true, text: result, timings: { queueWaitMs, threadStartMs, modelMs: Date.now() - modelStartedAt } };
67
+ }
68
+ catch (error) {
69
+ logger.error("Flow C Codex worker failed", { workerIndex, error });
70
+ const message = errorMessage(error);
71
+ emit("agent_error", { message });
72
+ return { ok: false, error: message, retryable: !isDeterministicWorkflowContractError(message), timings: { queueWaitMs, threadStartMs: threadStartMs || Date.now() - threadStartedAt, modelMs: modelStartedAt ? Date.now() - modelStartedAt : 0 } };
73
+ }
74
+ finally {
75
+ if (timer)
76
+ clearTimeout(timer);
77
+ options.onWorkerFinish?.();
78
+ }
79
+ });
80
+ }
81
+ /** Contract/preflight 4xx errors are deterministic and must never burn every fallback chunk size. */
82
+ export function isDeterministicWorkflowContractError(error) {
83
+ const message = errorMessage(error);
84
+ return /invalid_json_schema|invalid schema for response_format|text\.format\.schema|response[_ ]format[^\n]*(?:invalid|schema)/i.test(message);
85
+ }
86
+ async function startWorkflowCodexApp(workerIndex, emit) {
87
+ let started;
88
+ started = await CodexAppClient.start(emit, () => {
89
+ if (!started || workflowCodexApps.get(workerIndex) === started)
90
+ workflowCodexApps.delete(workerIndex);
91
+ });
92
+ workflowCodexApps.set(workerIndex, started);
93
+ return started;
94
+ }
27
95
  /** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
28
96
  export async function restartCodexApp(message = "Codex 执行超时,正在重启本机脚本引擎") {
29
97
  const app = codexApp;
@@ -0,0 +1,13 @@
1
+ /** 小型 FIFO worker pool;每个 worker 同时只执行一个任务。 */
2
+ export declare class WorkerPool {
3
+ readonly concurrency: number;
4
+ private available;
5
+ private waiters;
6
+ private active;
7
+ constructor(concurrency: number);
8
+ run<T>(task: (workerIndex: number, queueWaitMs: number) => Promise<T>): Promise<T>;
9
+ get activeCount(): number;
10
+ private acquire;
11
+ private release;
12
+ }
13
+ export declare function boundedWorkerConcurrency(value: unknown, fallback?: number, maximum?: number): number;
@@ -0,0 +1,45 @@
1
+ /** 小型 FIFO worker pool;每个 worker 同时只执行一个任务。 */
2
+ export class WorkerPool {
3
+ concurrency;
4
+ available;
5
+ waiters = [];
6
+ active = new Set();
7
+ constructor(concurrency) {
8
+ this.concurrency = concurrency;
9
+ if (!Number.isInteger(concurrency) || concurrency < 1)
10
+ throw new Error("worker concurrency must be a positive integer");
11
+ this.available = Array.from({ length: concurrency }, (_, index) => index);
12
+ }
13
+ async run(task) {
14
+ const queuedAt = Date.now();
15
+ const workerIndex = await this.acquire();
16
+ this.active.add(workerIndex);
17
+ try {
18
+ return await task(workerIndex, Date.now() - queuedAt);
19
+ }
20
+ finally {
21
+ this.release(workerIndex);
22
+ }
23
+ }
24
+ get activeCount() { return this.active.size; }
25
+ acquire() {
26
+ const workerIndex = this.available.shift();
27
+ if (workerIndex !== undefined)
28
+ return Promise.resolve(workerIndex);
29
+ return new Promise((resolve) => this.waiters.push(resolve));
30
+ }
31
+ release(workerIndex) {
32
+ this.active.delete(workerIndex);
33
+ const waiter = this.waiters.shift();
34
+ if (waiter)
35
+ waiter(workerIndex);
36
+ else
37
+ this.available.push(workerIndex);
38
+ }
39
+ }
40
+ export function boundedWorkerConcurrency(value, fallback = 2, maximum = 4) {
41
+ const parsed = Number(value);
42
+ if (!Number.isInteger(parsed) || parsed < 1)
43
+ return fallback;
44
+ return Math.min(parsed, maximum);
45
+ }
@@ -887,15 +887,15 @@ export declare const toolInputSchemas: {
887
887
  run: z.ZodOptional<z.ZodBoolean>;
888
888
  }, "strip", z.ZodTypeAny, {
889
889
  prompt: string;
890
- count?: number | undefined;
891
890
  model?: string | undefined;
891
+ count?: number | undefined;
892
892
  size?: string | undefined;
893
893
  quality?: string | undefined;
894
894
  run?: boolean | undefined;
895
895
  }, {
896
896
  prompt: string;
897
- count?: number | undefined;
898
897
  model?: string | undefined;
898
+ count?: number | undefined;
899
899
  size?: string | undefined;
900
900
  quality?: string | undefined;
901
901
  run?: boolean | undefined;
@@ -156,8 +156,8 @@ export declare function parseToolInput(name: ToolName, input: unknown): {
156
156
  limit?: number | undefined;
157
157
  } | {
158
158
  prompt: string;
159
- count?: number | undefined;
160
159
  model?: string | undefined;
160
+ count?: number | undefined;
161
161
  size?: string | undefined;
162
162
  quality?: string | undefined;
163
163
  run?: boolean | undefined;
@@ -1,8 +1,10 @@
1
- /** Flow C 脚本分段的唯一上限和自动降级顺序,MCP 与调度器必须共用。 */
2
- export declare const FLOW_C_SCRIPT_CHUNK_SIZES: readonly [30, 15, 10];
3
- export declare const FLOW_C_SCRIPT_CHUNK_MAX: 30;
1
+ /** 中心/MCP 兼容上限仍为 30;结构化 10 秒脚本按更小子批受控并行。 */
2
+ export declare const FLOW_C_SCRIPT_CHUNK_MAX = 30;
3
+ export declare const FLOW_C_SCRIPT_CHUNK_SIZES: readonly [10, 5, 1];
4
4
  /**
5
5
  * 长视频每条都包含总脚本和 2/3 套完整分镜,不能沿用短脚本的 30 条输出量。
6
6
  * 小段回传既能更早显示进度,也避免单个 Codex turn 因输出过大而被中断。
7
7
  */
8
- export declare function flowCScriptChunkSizes(durationSeconds: 10 | 20 | 30): readonly [30, 15, 10] | readonly [2, 1] | readonly [1];
8
+ export declare function flowCScriptChunkSizes(durationSeconds: 10 | 20 | 30): readonly [10, 5, 1] | readonly [2, 1] | readonly [1];
9
+ /** 将当前缺失 ordinal 切成可独立持久化的子批;长视频绝不恢复 30 条大回合。 */
10
+ export declare function flowCScriptChunks(durationSeconds: 10 | 20 | 30, ordinals: number[], size?: number): number[][];
@@ -1,6 +1,6 @@
1
- /** Flow C 脚本分段的唯一上限和自动降级顺序,MCP 与调度器必须共用。 */
2
- export const FLOW_C_SCRIPT_CHUNK_SIZES = [30, 15, 10];
3
- export const FLOW_C_SCRIPT_CHUNK_MAX = FLOW_C_SCRIPT_CHUNK_SIZES[0];
1
+ /** 中心/MCP 兼容上限仍为 30;结构化 10 秒脚本按更小子批受控并行。 */
2
+ export const FLOW_C_SCRIPT_CHUNK_MAX = 30;
3
+ export const FLOW_C_SCRIPT_CHUNK_SIZES = [10, 5, 1];
4
4
  /**
5
5
  * 长视频每条都包含总脚本和 2/3 套完整分镜,不能沿用短脚本的 30 条输出量。
6
6
  * 小段回传既能更早显示进度,也避免单个 Codex turn 因输出过大而被中断。
@@ -12,3 +12,10 @@ export function flowCScriptChunkSizes(durationSeconds) {
12
12
  return [1];
13
13
  return FLOW_C_SCRIPT_CHUNK_SIZES;
14
14
  }
15
+ /** 将当前缺失 ordinal 切成可独立持久化的子批;长视频绝不恢复 30 条大回合。 */
16
+ export function flowCScriptChunks(durationSeconds, ordinals, size = flowCScriptChunkSizes(durationSeconds)[0]) {
17
+ const chunks = [];
18
+ for (let index = 0; index < ordinals.length; index += size)
19
+ chunks.push(ordinals.slice(index, index + size));
20
+ return chunks;
21
+ }
@@ -14,14 +14,31 @@ type ScriptRecord = {
14
14
  message?: string;
15
15
  expiresAt?: string;
16
16
  attempts: number;
17
+ chunkSize?: number;
18
+ activeChunks?: number;
17
19
  priorityAt?: string;
18
20
  updatedAt: string;
19
21
  };
22
+ type ReferenceStyleCard = {
23
+ openingComposition?: unknown;
24
+ visualDensity?: unknown;
25
+ conflictContrast?: unknown;
26
+ characterProductEntrance?: unknown;
27
+ rhythm?: unknown;
28
+ turn?: unknown;
29
+ proofMethod?: unknown;
30
+ voiceTone?: unknown;
31
+ categoryFit?: unknown;
32
+ };
20
33
  type ScriptTask = {
21
34
  id: string;
22
35
  workflow: "flow-c";
23
36
  market: string;
24
37
  duration_seconds?: 10 | 20 | 30;
38
+ storyboard_layout_version?: "hybrid-three-anchor-v1" | "legacy-five-row-v1";
39
+ storyboardLayoutVersion?: "hybrid-three-anchor-v1" | "legacy-five-row-v1";
40
+ reference_style_card?: ReferenceStyleCard;
41
+ referenceStyleCard?: ReferenceStyleCard;
25
42
  requested_count: number;
26
43
  product_quantities: number[];
27
44
  instructions: string;
@@ -52,6 +69,8 @@ export declare class WorkflowManager {
52
69
  requestedCount: number;
53
70
  received: number;
54
71
  threadId: string | undefined;
72
+ chunkSize: number | null;
73
+ activeChunks: number;
55
74
  message: string | undefined;
56
75
  expiresAt: string | undefined;
57
76
  updatedAt: string;
@@ -62,6 +81,8 @@ export declare class WorkflowManager {
62
81
  requestedCount: number;
63
82
  received: number;
64
83
  threadId: string | undefined;
84
+ chunkSize: number | null;
85
+ activeChunks: number;
65
86
  message: string | undefined;
66
87
  expiresAt: string | undefined;
67
88
  updatedAt: string;
@@ -72,6 +93,8 @@ export declare class WorkflowManager {
72
93
  requestedCount: number;
73
94
  received: number;
74
95
  threadId: string | undefined;
96
+ chunkSize: number | null;
97
+ activeChunks: number;
75
98
  message: string | undefined;
76
99
  expiresAt: string | undefined;
77
100
  updatedAt: string;
@@ -103,6 +126,8 @@ export declare class WorkflowManager {
103
126
  };
104
127
  health(): {
105
128
  activeScriptHandoffIds: string[];
129
+ activeScripts: number;
130
+ scriptConcurrencyLimit: number;
106
131
  queuedScripts: number;
107
132
  blockedScripts: number;
108
133
  };
@@ -187,6 +212,10 @@ export declare class WorkflowManager {
187
212
  private pumpScriptQueue;
188
213
  private finishDownloadDirectorySelection;
189
214
  private runScript;
215
+ /** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
216
+ private runScriptChunk;
217
+ /** 只记录阶段、ordinal 和毫秒数;禁止记录 prompt、令牌或中心能力。 */
218
+ private emitScriptStage;
190
219
  /**
191
220
  * 中心批量校验失败时逐条补交,避免一条格式异常拖累同轮其他有效脚本。
192
221
  * 已接纳 ordinal 由中心持久化,下一轮只会继续缺失项。
@@ -200,4 +229,11 @@ export declare class WorkflowManager {
200
229
  private save;
201
230
  }
202
231
  export declare function compareScriptQueueRecords(left: Pick<ScriptRecord, "priorityAt" | "updatedAt">, right: Pick<ScriptRecord, "priorityAt" | "updatedAt">): number;
232
+ /** A deterministic response-format failure stops fallback isolation immediately. */
233
+ export declare function terminalScriptChunkError(results: Array<{
234
+ error?: string;
235
+ terminal?: boolean;
236
+ }>): string;
237
+ export declare function scriptChunkPrompt(id: string, task: ScriptTask, ordinals: number[]): string;
238
+ export declare function missingOrdinals(total: number, received: number[]): number[];
203
239
  export {};
@@ -5,11 +5,11 @@ import { mkdir, open, rename, stat, unlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
- import { restartCodexApp, runCodexTurn, startCodexThread } from "../agent/codex.js";
8
+ import { FLOW_C_CODEX_MODEL, FLOW_C_CODEX_REASONING_EFFORT, FLOW_C_CODEX_WORKER_CONCURRENCY, flowCCodexWorkerStatus, runCodexWorkflowTurn } from "../agent/codex.js";
9
9
  import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { windowsPowerShellExecutable } from "../utils/windows.js";
12
- import { FLOW_C_SCRIPT_CHUNK_MAX, flowCScriptChunkSizes } from "./constants.js";
12
+ import { FLOW_C_SCRIPT_CHUNK_MAX, flowCScriptChunks, flowCScriptChunkSizes } from "./constants.js";
13
13
  import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
14
14
  const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
15
15
  export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
@@ -27,6 +27,7 @@ export class WorkflowManager {
27
27
  this.config = config;
28
28
  this.emit = emit;
29
29
  for (const record of Object.values(this.state.scripts)) {
30
+ record.activeChunks = 0;
30
31
  if (record.status === "queued" || record.status === "running")
31
32
  this.scheduleScript(record.id);
32
33
  }
@@ -47,6 +48,8 @@ export class WorkflowManager {
47
48
  threadId: previous?.threadId,
48
49
  expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
49
50
  attempts: previous?.attempts || 0,
51
+ chunkSize: previous?.chunkSize,
52
+ activeChunks: 0,
50
53
  priorityAt: now(),
51
54
  message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
52
55
  updatedAt: now(),
@@ -59,6 +62,7 @@ export class WorkflowManager {
59
62
  const id = workflowId(idValue, "脚本交接 ID");
60
63
  const record = this.scriptRecord(id);
61
64
  record.status = "queued";
65
+ record.activeChunks = 0;
62
66
  // A failed manual attempt may leave a very large or interrupted thread.
63
67
  // Retrying in a fresh thread avoids carrying that damaged context forward.
64
68
  delete record.threadId;
@@ -93,7 +97,7 @@ export class WorkflowManager {
93
97
  const jobs = jobsValue;
94
98
  const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ jobs }) });
95
99
  if (Array.isArray(data.receivedOrdinals))
96
- record.receivedOrdinals = data.receivedOrdinals.map(Number).filter(Number.isInteger).sort((left, right) => left - right);
100
+ record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...data.receivedOrdinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
97
101
  else {
98
102
  const accepted = new Set(record.receivedOrdinals);
99
103
  for (const job of jobs)
@@ -117,8 +121,11 @@ export class WorkflowManager {
117
121
  }
118
122
  health() {
119
123
  const records = Object.values(this.state.scripts);
124
+ const workers = flowCCodexWorkerStatus();
120
125
  return {
121
126
  activeScriptHandoffIds: [...this.runningScripts],
127
+ activeScripts: workers.active,
128
+ scriptConcurrencyLimit: workers.limit,
122
129
  queuedScripts: records.filter((record) => record.status === "queued" || record.status === "running").length,
123
130
  blockedScripts: records.filter((record) => record.status === "error").length,
124
131
  };
@@ -230,66 +237,44 @@ export class WorkflowManager {
230
237
  if (Date.parse(task.expires_at) <= Date.now())
231
238
  throw new ExpiredCapabilityError("脚本交接已过期,请在网页重新点击交给本机 Codex");
232
239
  const workspace = ensureSiteWorkspace(this.config);
233
- if (!record.threadId) {
234
- const thread = await startCodexThread(this.emit, workspace.workspacePath, "full");
235
- record.threadId = String(thread.id || "");
236
- this.save();
237
- }
238
240
  const durationSeconds = Number(task.duration_seconds || 10);
239
241
  const chunkSizes = flowCScriptChunkSizes(durationSeconds);
242
+ // Fail locally before starting any worker if a future schema edit
243
+ // violates strict response-format invariants.
244
+ for (const chunkSize of new Set(chunkSizes))
245
+ flowCScriptOutputSchema(durationSeconds, chunkSize);
246
+ record.activeChunks = 0;
247
+ let chunkSizeIndex = 0;
240
248
  while (record.receivedOrdinals.length < task.requested_count) {
241
- let progressed = false;
242
- let lastRange = "";
243
- let lastError = "";
244
- for (const [attemptIndex, chunkSize] of chunkSizes.entries()) {
245
- const missing = missingOrdinals(task.requested_count, record.receivedOrdinals).slice(0, chunkSize);
246
- if (!missing.length) {
247
- progressed = true;
248
- break;
249
- }
250
- lastRange = `${missing[0]}–${missing.at(-1)}`;
251
- const before = record.receivedOrdinals.length;
252
- record.attempts += 1;
253
- record.message = `本机 Codex 正在写第 ${missing[0]}–${missing.at(-1)} 条(总计 ${task.requested_count} 条)`;
249
+ const before = record.receivedOrdinals.length;
250
+ const missing = missingOrdinals(task.requested_count, record.receivedOrdinals);
251
+ const chunkSize = chunkSizes[chunkSizeIndex];
252
+ const chunks = flowCScriptChunks(durationSeconds, missing, chunkSize);
253
+ record.chunkSize = chunkSize;
254
+ record.attempts += chunks.length;
255
+ record.message = `本机 Codex 正在用 ${FLOW_C_CODEX_WORKER_CONCURRENCY} 个受控 worker 写 ${chunks.length} 个独立子批(${before}/${task.requested_count})`;
256
+ record.updatedAt = now();
257
+ this.save();
258
+ const results = await Promise.all(chunks.map((ordinals) => this.runScriptChunk(id, task, ordinals, workspace.workspacePath)));
259
+ await this.scriptTask(id);
260
+ const terminalError = terminalScriptChunkError(results);
261
+ if (terminalError) {
262
+ throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalError})`);
263
+ }
264
+ const progressed = record.receivedOrdinals.length > before;
265
+ if (progressed) {
266
+ chunkSizeIndex = 0;
267
+ continue;
268
+ }
269
+ const lastError = results.map((result) => result.error).filter(Boolean).at(-1) || "Codex 未返回可用的结构化脚本";
270
+ if (chunkSizeIndex < chunkSizes.length - 1) {
271
+ chunkSizeIndex += 1;
272
+ record.message = `未成功回传,正在把缺失 ordinal 缩小为每段 ${chunkSizes[chunkSizeIndex]} 条并行重试`;
254
273
  record.updatedAt = now();
255
274
  this.save();
256
- const result = await runCodexScriptWithTimeout(scriptChunkPrompt(id, task, missing), this.emit, { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", outputSchema: flowCScriptOutputSchema(durationSeconds, missing.length), onThread: (threadId) => { record.threadId = threadId; this.save(); } });
257
- await this.scriptTask(id);
258
- progressed = record.receivedOrdinals.length > before;
259
- if (!progressed && result.ok && result.text) {
260
- try {
261
- await this.submitGeneratedScriptJobs(id, parseFlowCScriptOutput(result.text, missing));
262
- await this.scriptTask(id);
263
- progressed = record.receivedOrdinals.length > before;
264
- }
265
- catch (error) {
266
- lastError = error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验";
267
- }
268
- }
269
- if (progressed) {
270
- // Each long structured result is large. A fresh thread keeps the
271
- // next chunk from re-reading prior outputs while ordinals preserve
272
- // creative rotation and the center keeps accepted work durable.
273
- if (record.receivedOrdinals.length < task.requested_count) {
274
- const thread = await startCodexThread(this.emit, workspace.workspacePath, "full");
275
- record.threadId = String(thread.id || "");
276
- this.save();
277
- }
278
- break;
279
- }
280
- // Keep provider/client details out of the browser-facing state. The
281
- // underlying Codex runner already records the technical error locally.
282
- lastError ||= result.ok ? "Codex 未返回可用的结构化脚本" : "Codex 本轮执行失败";
283
- if (attemptIndex < chunkSizes.length - 1) {
284
- const nextSize = chunkSizes[attemptIndex + 1];
285
- record.message = `第 ${lastRange} 条未成功回传,正在换新会话并缩小为每段 ${nextSize} 条重试`;
286
- const thread = await startCodexThread(this.emit, workspace.workspacePath, "full");
287
- record.threadId = String(thread.id || "");
288
- this.save();
289
- }
275
+ continue;
290
276
  }
291
- if (!progressed)
292
- throw new Error(`本机 Codex 已自动换会话并降级到每段 ${chunkSizes.at(-1)} 条,仍未回传第 ${lastRange} 条${lastError ? `(${lastError})` : ""},请点击重试`);
277
+ throw new Error(`本机 Codex 已自动隔离失败 ordinal 并降级到每段 ${chunkSizes.at(-1)} 条,仍未回传缺失脚本(${lastError}),请点击重试`);
293
278
  }
294
279
  const finalTask = await this.scriptTask(id);
295
280
  if (finalTask.status === "ready" && record.receivedOrdinals.length === task.requested_count) {
@@ -306,10 +291,45 @@ export class WorkflowManager {
306
291
  }
307
292
  finally {
308
293
  record.updatedAt = now();
294
+ record.activeChunks = 0;
309
295
  this.save();
310
296
  this.runningScripts.delete(id);
311
297
  }
312
298
  }
299
+ /** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
300
+ async runScriptChunk(id, task, ordinals, cwd) {
301
+ const durationSeconds = Number(task.duration_seconds || 10);
302
+ const result = await runCodexWorkflowTurn(scriptChunkPrompt(id, task, ordinals), this.emit, {
303
+ cwd,
304
+ permissionMode: "full",
305
+ timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
306
+ outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
307
+ onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
308
+ onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
309
+ onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
310
+ });
311
+ this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
312
+ this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
313
+ this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
314
+ if (!result.ok || !result.text)
315
+ return { error: result.ok ? "Codex 未返回可用的结构化脚本" : result.error, terminal: !result.ok && !result.retryable };
316
+ try {
317
+ const parseStartedAt = Date.now();
318
+ const jobs = parseFlowCScriptOutput(result.text, ordinals);
319
+ this.emitScriptStage(id, ordinals, "parse", Date.now() - parseStartedAt);
320
+ const persistStartedAt = Date.now();
321
+ await this.submitGeneratedScriptJobs(id, jobs);
322
+ this.emitScriptStage(id, ordinals, "persist", Date.now() - persistStartedAt);
323
+ return { terminal: false };
324
+ }
325
+ catch (error) {
326
+ return { error: error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验", terminal: false };
327
+ }
328
+ }
329
+ /** 只记录阶段、ordinal 和毫秒数;禁止记录 prompt、令牌或中心能力。 */
330
+ emitScriptStage(handoffId, ordinals, stage, durationMs) {
331
+ this.emit("agent_event", { agent: "codex", type: "workflow.script.stage", handoffId, ordinals, stage, duration_ms: Math.max(0, Math.round(durationMs)), model: FLOW_C_CODEX_MODEL, reasoning: FLOW_C_CODEX_REASONING_EFFORT });
332
+ }
313
333
  /**
314
334
  * 中心批量校验失败时逐条补交,避免一条格式异常拖累同轮其他有效脚本。
315
335
  * 已接纳 ordinal 由中心持久化,下一轮只会继续缺失项。
@@ -475,44 +495,48 @@ export function compareScriptQueueRecords(left, right) {
475
495
  return String(right.priorityAt || "").localeCompare(String(left.priorityAt || ""));
476
496
  return left.updatedAt.localeCompare(right.updatedAt);
477
497
  }
478
- async function runCodexScriptWithTimeout(prompt, emit, options) {
479
- let timer;
480
- const turn = runCodexTurn(prompt, emit, [], options);
481
- const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), FLOW_C_CODEX_TURN_TIMEOUT_MS); });
482
- try {
483
- const result = await Promise.race([turn, timeout]);
484
- if (result !== "timeout")
485
- return result;
486
- await restartCodexApp("Flow C 脚本回合超过 8 分钟,已自动终止并换新会话");
487
- await turn;
488
- return { ok: false, error: "Flow C 脚本回合超过 8 分钟,已自动终止并换新会话" };
489
- }
490
- finally {
491
- if (timer)
492
- clearTimeout(timer);
493
- }
498
+ /** A deterministic response-format failure stops fallback isolation immediately. */
499
+ export function terminalScriptChunkError(results) {
500
+ return results.find((result) => result.terminal)?.error || "";
494
501
  }
495
502
  class ExpiredCapabilityError extends Error {
496
503
  }
497
- function scriptChunkPrompt(id, task, ordinals) {
504
+ export function scriptChunkPrompt(id, task, ordinals) {
498
505
  const duration = Number(task.duration_seconds || 10);
499
- const longVideoRules = duration === 10 ? "" : `
500
- 本任务每条成片为 ${duration} 秒。先创作一份完整连贯的 masterScript,再严格拆成 ${duration / 10} 个可以独立交给视频模型的 10 秒 segments;每一段内部时间都从 0–10 秒重新写,绝不能引用“上一条视频”或写 10–20、20–30 秒这种模型无法理解的时间。
501
- 每条只需回传一份 masterScript 和完整 segments;不要再重复输出内容相同的顶层 script,Agent 会从 masterScript 自动补齐兼容字段。segments[0].continuityMode 必须是 reset;同人物同场景延续时后续段用 continue,明确换人物或换场景才用 reset。continue 段必须复述并固定人物年龄、外貌、服装、场景陈设、光线、机位基调和商品当前状态。
502
- 每份 masterScript 和每个 segment 都要写同一套详细音色身份:性别、年龄段、音高、音质、语速、口音、停连和说话习惯;情绪随剧情逐段变化,但音色身份不变。仍按短视频快节奏口播,不因时长增加而拖慢。`;
506
+ const layoutVersion = task.storyboard_layout_version || task.storyboardLayoutVersion || "hybrid-three-anchor-v1";
507
+ const styleCard = compactReferenceStyleCard(task.reference_style_card || task.referenceStyleCard);
508
+ const durationRules = duration === 10 ? `
509
+ 本任务每条成片为 10 秒,不生成 masterScript。每条只输出一份 openingState 和一个完整 0–10 秒 segment;Agent 会从 structured shots 确定性渲染兼容顶层 script,并按 shots 顺序派生 segmentVoiceovers,模型不要生成这两个派生字段。` : `
510
+ 本任务每条成片为 ${duration} 秒。先创作一份完整连贯的 masterScript,再严格展开成 ${duration / 10} 个独立的 0–10 秒 segments;绝不能写 10–20、20–30 秒,也不能让局部段携带总片时轴。
511
+ 每条只需回传一份 masterScript、openingState 和完整 segments;不要输出重复的顶层 script 或 segmentVoiceovers。Agent 会从 masterScript 补顶层兼容 script、按 shots 派生 segmentVoiceovers,并把 openingState 变成首段起点、用前一段 endingState 确定性补齐后一段 continuity。`;
503
512
  return `你正在后台处理“抖音小辉跨境工具”Flow C 脚本交接 ${id}。
504
513
  完整中心任务已经附在本提示词末尾。只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
505
514
  本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
506
- 脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${longVideoRules}
515
+ 脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${durationRules}
516
+ 每条先按“画面先行”写 creativePlan,再写本段/总脚本。creativePlan 必须给出 visualHook、conflict、productIntervention、visibleProof、callbackMotivation、truthBoundary、differentiationKey,并在 escalation 或 turn 中至少给出一项真实成立的升级/转折;format 必须依据当前产品、市场、事实证据和真实场景适配选择。中国短剧式冲突/喜剧只能是适合时的实验路线,绝不是默认;工厂、仓库、超市等条件场景也只有在商品动作自然且不伪造来源、库存或销量证据时才可选,不能固定轮换。
517
+ styleCardSummary 只使用已缓存的压缩风格卡,没有就明确写“无”;不要分析、读取或转录原始参考视频。每条只生成一份 voiceProfile(性别、年龄感、音高、音质、语速、口音、停顿习惯、情绪基调),所有局部段共同引用它;每段仅用简短 voiceCue 表达情绪变化,不复写整套音色。
518
+ 每个 segment 必须有完整 0–10 秒 shots、voiceCue 和 endingState;不要让模型生成 continuityMode 或 continuity。每个 shot 都包含画面、准确本地口播、可控屏幕字、商品证据、soundBgm 与 emotionalNote。qualityGate 必须逐项用具体证据核对强钩子、冲突、升级/转折、商品介入、可见证明、开头回扣购买动机、事实边界与批内差异,禁止用“已满足”空话。
519
+ 每条 differentiationKey 必须在带货形式、第一眼画面、剧情骨架、痛点/反差、商品证明和口播气质上与本批其他条目显著不同,不能只换措辞。
520
+ 本脚本回合固定使用 GPT-5.6 Terra 高推理;不要建议换低档模型或降低推理。故事版布局由中心顶层 storyboardLayoutVersion=${layoutVersion} 管理,不要让模型选择,也不要把它重复进每条 job。
521
+ 【已缓存压缩的参考 style card】${styleCard || "无参考风格卡"}【style card 结束】
507
522
  每一个 ordinal 都必须从头到尾创作一份完整、独立的原生带货脚本,画面和口播必须在同一份脚本中一起独立构思。先让它成为一个当地真人在具体生活情境里自然发现、吐槽、试用或验证商品的短内容,再完成带货;禁止写成品牌广告片、棚拍宣传片、电视购物、逐条念卖点或全程完美对镜讲解。独立脚本天然包含独立口播:不得把任何一份口播当作整批公共模板,不得复用完整台词,也不得只换人物、场景或少数词后保留近似口播。音色身份可以为同一人物保持一致,但每条的 Hook、人物行动、证明表达和 CTA 都必须重新写。
508
- 用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
509
- 工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
510
- 只返回符合当前结构化输出契约的 jobs;10 秒任务每条包含 ordinal、productIndex、sellingFormId 和 script;20/30 秒任务每条包含 ordinal、productIndex、sellingFormId、masterScript 与完整 segments,中心任务示例里的重复顶层 script 由 Agent 自动补齐。无需调用 MCP,Agent 会在本机校验后自动回传。不要创建付费批次,不要调用供应商模型。
523
+ 用户没有指定带货方向时,按当前商品可见卖点、自然使用环境、目标市场表达、事实边界和批内差异动态选择;有条件证据门的形式只有中心任务已提供相应真实证据时才能采用。
524
+ 只返回符合当前结构化输出契约的 jobs;每条都包含 ordinal、productIndex、sellingFormId、creativePlan、voiceProfile、qualityGate、openingState 和结构化局部段。10 秒只返回一个 segment;20/30 秒另含 masterScript 与完整 segments。兼容 script、continuity 和 segmentVoiceovers 全由 Agent 确定性补齐。无需调用 MCP,Agent 会在本机校验后自动回传。不要创建付费批次,不要调用供应商模型。
511
525
 
512
526
  【中心任务完整 instructions】
513
527
  ${task.instructions}
514
528
  【中心任务 instructions 结束】`;
515
529
  }
530
+ function compactReferenceStyleCard(value) {
531
+ if (!value || typeof value !== "object")
532
+ return "";
533
+ const fields = ["openingComposition", "visualDensity", "conflictContrast", "characterProductEntrance", "rhythm", "turn", "proofMethod", "voiceTone", "categoryFit"];
534
+ const compact = Object.fromEntries(fields.flatMap((field) => {
535
+ const text = String(value[field] || "").trim().replace(/\s+/g, " ").slice(0, 400);
536
+ return text ? [[field, text]] : [];
537
+ }));
538
+ return Object.keys(compact).length ? JSON.stringify(compact) : "";
539
+ }
516
540
  function scriptProductAssignments(productQuantities, ordinals) {
517
541
  const assignments = [];
518
542
  let first = ordinals[0];
@@ -540,12 +564,12 @@ function productIndexForOrdinal(productQuantities, ordinal) {
540
564
  return -1;
541
565
  }
542
566
  function publicScript(record) {
543
- return { id: record.id, status: record.status, requestedCount: record.requestedCount, received: record.receivedOrdinals.length, threadId: record.threadId, message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
567
+ return { id: record.id, status: record.status, requestedCount: record.requestedCount, received: record.receivedOrdinals.length, threadId: record.threadId, chunkSize: record.chunkSize || null, activeChunks: Number(record.activeChunks || 0), message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
544
568
  }
545
569
  function publicDownload(record) {
546
570
  return { batchId: record.batchId, status: record.status, downloaded: record.downloadedOrdinals.length, market: record.market, message: record.message, expiresAt: record.expiresAt, updatedAt: record.updatedAt };
547
571
  }
548
- function missingOrdinals(total, received) { const set = new Set(received); return Array.from({ length: total }, (_, index) => index + 1).filter((ordinal) => !set.has(ordinal)); }
572
+ export function missingOrdinals(total, received) { const set = new Set(received); return Array.from({ length: total }, (_, index) => index + 1).filter((ordinal) => !set.has(ordinal)); }
549
573
  function workflowId(value, label) { const id = String(value || "").trim(); if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(id))
550
574
  throw new Error(`${label}无效`); return id; }
551
575
  function secret(value, label) { const token = String(value || "").trim(); if (token.length < 32 || token.length > 512)
@@ -1,5 +1,11 @@
1
1
  type JsonSchema = Record<string, unknown>;
2
2
  /** Codex app-server 的结构化脚本返回契约;回传由 Agent 完成,不依赖模型调用 MCP。 */
3
3
  export declare function flowCScriptOutputSchema(durationSeconds: 10 | 20 | 30, count: number): JsonSchema;
4
+ /**
5
+ * OpenAI strict structured output requires every declared object property to
6
+ * appear in `required`. Optional semantics must therefore be represented by a
7
+ * required nullable field, never by omitting that key from `required`.
8
+ */
9
+ export declare function assertStrictResponseSchema(schemaValue: unknown, path?: string): asserts schemaValue is JsonSchema;
4
10
  export declare function parseFlowCScriptOutput(value: string, expectedOrdinals: number[]): unknown[];
5
11
  export {};
@@ -1,4 +1,5 @@
1
1
  const text = { type: "string", minLength: 1 };
2
+ const nullableText = { anyOf: [text, { type: "null" }] };
2
3
  const continuityFields = ["character", "wardrobe", "location", "lighting", "productState", "unfinishedAction", "nextGoal"];
3
4
  function object(properties, required = Object.keys(properties)) {
4
5
  return { type: "object", properties, required, additionalProperties: false };
@@ -6,10 +7,52 @@ function object(properties, required = Object.keys(properties)) {
6
7
  function continuitySchema(frameField) {
7
8
  return object(Object.fromEntries([...continuityFields, frameField].map((field) => [field, text])));
8
9
  }
10
+ function openingStateSchema() {
11
+ return object(Object.fromEntries([...continuityFields, "openingFrame"].map((field) => [field, text])));
12
+ }
13
+ function voiceProfileSchema() {
14
+ return object({
15
+ gender: text,
16
+ ageImpression: text,
17
+ pitch: text,
18
+ timbre: text,
19
+ speakingRate: text,
20
+ accent: text,
21
+ pauseHabit: text,
22
+ emotionalBaseline: text,
23
+ });
24
+ }
25
+ function creativePlanSchema() {
26
+ const properties = {
27
+ format: text,
28
+ visualHook: text,
29
+ conflict: text,
30
+ escalation: nullableText,
31
+ turn: nullableText,
32
+ productIntervention: text,
33
+ visibleProof: text,
34
+ callbackMotivation: text,
35
+ truthBoundary: text,
36
+ differentiationKey: text,
37
+ styleCardSummary: nullableText,
38
+ };
39
+ return object(properties);
40
+ }
41
+ function qualityGateSchema() {
42
+ return object({
43
+ hook: text,
44
+ conflict: text,
45
+ escalationOrTurn: text,
46
+ productIntervention: text,
47
+ visibleProof: text,
48
+ callbackMotivation: text,
49
+ truthBoundary: text,
50
+ batchDifferentiation: text,
51
+ });
52
+ }
9
53
  function segmentSchema() {
10
54
  return object({
11
- continuityMode: { type: "string", enum: ["reset", "continue"] },
12
- continuity: continuitySchema("previousEndingFrame"),
55
+ voiceCue: text,
13
56
  endingState: continuitySchema("endingFrame"),
14
57
  shots: {
15
58
  type: "array",
@@ -22,6 +65,8 @@ function segmentSchema() {
22
65
  voiceover: text,
23
66
  onScreenText: text,
24
67
  evidence: text,
68
+ soundBgm: text,
69
+ emotionalNote: text,
25
70
  }),
26
71
  },
27
72
  });
@@ -32,14 +77,51 @@ export function flowCScriptOutputSchema(durationSeconds, count) {
32
77
  ordinal: { type: "integer", minimum: 1 },
33
78
  productIndex: { type: "integer", minimum: 0 },
34
79
  sellingFormId: text,
80
+ creativePlan: creativePlanSchema(),
81
+ voiceProfile: voiceProfileSchema(),
82
+ qualityGate: qualityGateSchema(),
83
+ openingState: openingStateSchema(),
35
84
  };
36
85
  if (durationSeconds === 10)
37
- properties.script = { type: "string", minLength: 40 };
86
+ properties.segment = segmentSchema();
38
87
  else {
39
88
  properties.masterScript = { type: "string", minLength: 40 };
40
89
  properties.segments = { type: "array", minItems: durationSeconds / 10, maxItems: durationSeconds / 10, items: segmentSchema() };
41
90
  }
42
- return object({ jobs: { type: "array", minItems: count, maxItems: count, items: object(properties) } });
91
+ const schema = object({ jobs: { type: "array", minItems: count, maxItems: count, items: object(properties) } });
92
+ assertStrictResponseSchema(schema);
93
+ return schema;
94
+ }
95
+ /**
96
+ * OpenAI strict structured output requires every declared object property to
97
+ * appear in `required`. Optional semantics must therefore be represented by a
98
+ * required nullable field, never by omitting that key from `required`.
99
+ */
100
+ export function assertStrictResponseSchema(schemaValue, path = "$") {
101
+ const schema = recordOf(schemaValue);
102
+ if (!schema)
103
+ throw new Error(`Strict response schema at ${path} must be an object`);
104
+ if (schema.type === "object") {
105
+ const properties = recordOf(schema.properties);
106
+ if (!properties)
107
+ throw new Error(`Strict response schema object at ${path} needs properties`);
108
+ const keys = Object.keys(properties).sort();
109
+ const required = Array.isArray(schema.required) ? schema.required.map(String).sort() : [];
110
+ if (keys.length !== required.length || keys.some((key, index) => key !== required[index])) {
111
+ throw new Error(`Strict response schema object at ${path} must require every property`);
112
+ }
113
+ if (schema.additionalProperties !== false)
114
+ throw new Error(`Strict response schema object at ${path} must disable additional properties`);
115
+ for (const [key, child] of Object.entries(properties))
116
+ assertStrictResponseSchema(child, `${path}.properties.${key}`);
117
+ }
118
+ if (schema.items)
119
+ assertStrictResponseSchema(schema.items, `${path}.items`);
120
+ for (const branchKey of ["anyOf", "oneOf", "allOf"]) {
121
+ const branches = schema[branchKey];
122
+ if (Array.isArray(branches))
123
+ branches.forEach((branch, index) => assertStrictResponseSchema(branch, `${path}.${branchKey}[${index}]`));
124
+ }
43
125
  }
44
126
  export function parseFlowCScriptOutput(value, expectedOrdinals) {
45
127
  const source = String(value || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
@@ -64,14 +146,24 @@ export function parseFlowCScriptOutput(value, expectedOrdinals) {
64
146
  */
65
147
  function canonicalizeSegmentContinuity(jobValue) {
66
148
  const job = recordOf(jobValue);
67
- if (!job || !Array.isArray(job.segments))
149
+ if (!job)
68
150
  return jobValue;
151
+ const openingState = recordOf(job.openingState);
152
+ if (!Array.isArray(job.segments)) {
153
+ const segment = recordOf(job.segment);
154
+ if (!segment)
155
+ return jobValue;
156
+ const canonical = { ...segment, continuityMode: "reset", continuity: continuityFromOpeningState(openingState) };
157
+ const rendered = withLegacySegmentScript(canonical, 0, 1, job.voiceProfile);
158
+ const { openingState: _openingState, segment: _segment, ...persistedJob } = job;
159
+ return { ...persistedJob, script: recordOf(rendered)?.script, segmentVoiceovers: [segmentVoiceoverLines(canonical)], segment: rendered };
160
+ }
69
161
  const segments = job.segments.map((segmentValue, index, source) => {
70
162
  const segment = recordOf(segmentValue);
71
163
  if (!segment)
72
164
  return segmentValue;
73
165
  if (index === 0)
74
- return { ...segment, continuityMode: "reset" };
166
+ return { ...segment, continuityMode: "reset", continuity: continuityFromOpeningState(openingState) };
75
167
  const previous = recordOf(source[index - 1]);
76
168
  const endingState = recordOf(previous?.endingState);
77
169
  if (!endingState)
@@ -80,23 +172,36 @@ function canonicalizeSegmentContinuity(jobValue) {
80
172
  continuity.previousEndingFrame = endingState.endingFrame;
81
173
  return { ...segment, continuityMode: "continue", continuity };
82
174
  });
83
- return { ...job, script: job.masterScript || job.script, segments: segments.map((segment, index) => withLegacySegmentScript(segment, index, segments.length)) };
175
+ const { openingState: _openingState, ...persistedJob } = job;
176
+ return { ...persistedJob, script: job.masterScript || job.script, segmentVoiceovers: segments.map(segmentVoiceoverLines), segments: segments.map((segment, index) => withLegacySegmentScript(segment, index, segments.length, job.voiceProfile)) };
177
+ }
178
+ function segmentVoiceoverLines(segmentValue) {
179
+ const segment = recordOf(segmentValue);
180
+ const shots = Array.isArray(segment?.shots) ? segment.shots.map(recordOf).filter((shot) => Boolean(shot)) : [];
181
+ return shots.map((shot) => String(shot.voiceover || "").trim()).filter((line) => line && !/^(none|无|sin voz|sin diálogo)$/i.test(line));
182
+ }
183
+ function continuityFromOpeningState(openingState) {
184
+ const continuity = Object.fromEntries(continuityFields.map((field) => [field, openingState?.[field] || "由本段首镜建立"]));
185
+ continuity.previousEndingFrame = openingState?.openingFrame || "本段首镜";
186
+ return continuity;
84
187
  }
85
188
  /**
86
189
  * 正式站可能仍运行只接收 segment.script 的旧协议。脚本正文直接由同一份
87
190
  * structured shots/continuity 渲染,既不要求模型重复输出,也不改变新协议内容。
88
191
  */
89
- function withLegacySegmentScript(segmentValue, segmentIndex, segmentCount) {
192
+ function withLegacySegmentScript(segmentValue, segmentIndex, segmentCount, voiceProfileValue) {
90
193
  const segment = recordOf(segmentValue);
91
194
  const continuity = recordOf(segment?.continuity);
92
195
  const endingState = recordOf(segment?.endingState);
196
+ const voiceProfile = recordOf(voiceProfileValue);
93
197
  const shots = Array.isArray(segment?.shots) ? segment.shots.map(recordOf).filter((shot) => Boolean(shot)) : [];
94
198
  if (!segment || !continuity || !endingState || !shots.length)
95
199
  return segmentValue;
96
200
  const context = `人物:${continuity.character};服装:${continuity.wardrobe};地点:${continuity.location};光线:${continuity.lighting};商品状态:${continuity.productState};承接动作:${continuity.unfinishedAction};本段目标:${continuity.nextGoal};上一段结尾画面:${continuity.previousEndingFrame}`;
97
201
  const ending = `人物:${endingState.character};服装:${endingState.wardrobe};地点:${endingState.location};光线:${endingState.lighting};商品状态:${endingState.productState};未完成动作:${endingState.unfinishedAction};下一段目标:${endingState.nextGoal};本段结尾画面:${endingState.endingFrame}`;
98
- const timeline = shots.map((shot, index) => `镜头 ${index + 1}|${shot.startSeconds}–${shot.endSeconds} 秒|画面:${shot.visual}|口播:${shot.voiceover}|屏幕字:${shot.onScreenText}|证据:${shot.evidence}`).join("\n");
99
- return { ...segment, script: `Flow C 独立分段 ${segmentIndex + 1}/${segmentCount}\n局部时轴:0–10 秒;不得引用或绘制总片时间轴。\n本段起始连续性:${context}\n${timeline}\n本段结束状态:${ending}` };
202
+ const voice = voiceProfile ? `性别:${voiceProfile.gender};年龄感:${voiceProfile.ageImpression};音高:${voiceProfile.pitch};音质:${voiceProfile.timbre};语速:${voiceProfile.speakingRate};口音:${voiceProfile.accent};停顿习惯:${voiceProfile.pauseHabit};情绪基调:${voiceProfile.emotionalBaseline}` : "沿用本条共同音色档案";
203
+ const timeline = shots.map((shot, index) => `镜头 ${index + 1}|${shot.startSeconds}–${shot.endSeconds} 秒|画面:${shot.visual}|口播:${shot.voiceover}|屏幕字:${shot.onScreenText}|证据:${shot.evidence}|声音/BGM:${shot.soundBgm}|情绪:${shot.emotionalNote}`).join("\n");
204
+ return { ...segment, script: `Flow C 独立分段 ${segmentIndex + 1}/${segmentCount}\n局部时轴:0–10 秒;不得引用或绘制总片时间轴。\n共同音色档案:${voice}\n本段音色提示:${segment.voiceCue}\n本段起始连续性:${context}\n${timeline}\n本段结束状态:${ending}` };
100
205
  }
101
206
  function recordOf(value) {
102
207
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.48",
3
+ "version": "0.4.50",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",