完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。
- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。 - 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。 - 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。 - 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。 - 同步世界模拟器模块提示、编排文档与相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+147
-106
@@ -5,6 +5,7 @@ import type {
|
||||
CompleteWithToolsResult,
|
||||
LlmProvider,
|
||||
StreamCallbacks,
|
||||
TokenUsage,
|
||||
} from "./client.js";
|
||||
import {
|
||||
recordTokenUsage,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
buildContextTrace,
|
||||
type LlmContextTrace,
|
||||
} from "../types/context-trace.js";
|
||||
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js";
|
||||
|
||||
export type LlmTrackingContext = {
|
||||
sessionId?: string;
|
||||
@@ -41,6 +43,77 @@ function capturePendingTrace(
|
||||
});
|
||||
}
|
||||
|
||||
function recordUsage(
|
||||
ctx: LlmTrackingContext,
|
||||
caller: string | undefined,
|
||||
model: string | undefined,
|
||||
usage: TokenUsage | undefined,
|
||||
): void {
|
||||
if (!usage) return;
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: caller ?? "unknown",
|
||||
model: model ?? "unknown",
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
cacheMissTokens: usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
|
||||
function summarizeComplete(result: CompleteResult): string {
|
||||
const bits = [
|
||||
result.model ? `模型=${result.model}` : "",
|
||||
`思维链=${result.reasoning?.trim().length ?? 0}字`,
|
||||
`正文=${result.content?.length ?? 0}字`,
|
||||
];
|
||||
if (result.usage) {
|
||||
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
|
||||
}
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function summarizeTools(result: CompleteWithToolsResult): string {
|
||||
const tools =
|
||||
result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无";
|
||||
const bits = [
|
||||
result.model ? `模型=${result.model}` : "",
|
||||
`思维链=${result.reasoning?.trim().length ?? 0}字`,
|
||||
`正文=${result.content?.length ?? 0}字`,
|
||||
`工具=${tools}`,
|
||||
];
|
||||
if (result.usage) {
|
||||
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
|
||||
}
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
async function loggedLlm<T>(
|
||||
kind: string,
|
||||
caller: string | undefined,
|
||||
run: () => Promise<T>,
|
||||
summarize: (result: T) => string,
|
||||
): Promise<T> {
|
||||
const who = labelCaller(caller);
|
||||
const mode = labelLlmMode(kind);
|
||||
const t0 = Date.now();
|
||||
debugLog("llm", `开始 ${who} · ${mode}`);
|
||||
try {
|
||||
const result = await run();
|
||||
debugLog("llm", `结束 ${who} · ${mode} ${Date.now() - t0}毫秒 ${summarize(result)}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
debugLog("llm", `失败 ${who} · ${mode} ${Date.now() - t0}毫秒 ${detail.slice(0, 160)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenTrackingProvider implements LlmProvider {
|
||||
constructor(
|
||||
private readonly inner: LlmProvider,
|
||||
@@ -51,29 +124,21 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
messages: Parameters<LlmProvider["complete"]>[0],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const result = await this.inner.complete(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options?.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
return loggedLlm(
|
||||
"complete",
|
||||
options?.caller,
|
||||
async () => {
|
||||
const result = await this.inner.complete(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeComplete,
|
||||
);
|
||||
}
|
||||
|
||||
async completeStream(
|
||||
@@ -88,67 +153,51 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||
return result;
|
||||
}
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeStream(messages, options, {
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
return loggedLlm(
|
||||
"stream",
|
||||
options?.caller,
|
||||
async () => {
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeStream!(messages, options, {
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
},
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options?.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
summarizeComplete,
|
||||
);
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
messages: Parameters<LlmProvider["completeWithTools"]>[0],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const result = await this.inner.completeWithTools(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
return loggedLlm(
|
||||
"tools",
|
||||
options.caller,
|
||||
async () => {
|
||||
const result = await this.inner.completeWithTools(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeTools,
|
||||
);
|
||||
}
|
||||
|
||||
async completeWithToolsStream(
|
||||
@@ -160,37 +209,29 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
if (!inner.completeWithToolsStream) {
|
||||
return this.completeWithTools(messages, options);
|
||||
}
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeWithToolsStream(messages, options, {
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
return loggedLlm(
|
||||
"tools-stream",
|
||||
options.caller,
|
||||
async () => {
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeWithToolsStream!(messages, options, {
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
},
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
summarizeTools,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ApplyEventResult,
|
||||
RuntimeEvent,
|
||||
RuntimePhase,
|
||||
RuntimeSession,
|
||||
WaitingReason,
|
||||
} from "./types/runtime.js";
|
||||
import { getUserDataDir } from "./config/user-data-dir.js";
|
||||
|
||||
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
|
||||
const KIND_LABEL = {
|
||||
llm: "模型",
|
||||
state: "状态",
|
||||
step: "步骤",
|
||||
} as const;
|
||||
|
||||
const PHASE_LABEL: Record<RuntimePhase, string> = {
|
||||
idle: "空闲",
|
||||
running: "执行中",
|
||||
waiting_user: "等待用户",
|
||||
done: "完成",
|
||||
error: "出错",
|
||||
};
|
||||
|
||||
const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
|
||||
skill_selection: "选能力包",
|
||||
intake: "收集需求",
|
||||
input: "等待输入",
|
||||
approve_step: "确认开干",
|
||||
review_artifact: "验收产物",
|
||||
worker_questions: "执行单元提问",
|
||||
revision: "修订",
|
||||
next_intent: "下一步意向",
|
||||
};
|
||||
|
||||
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
session_started: "会话开始",
|
||||
skill_selected: "已选能力包",
|
||||
user_submitted_input: "用户发言",
|
||||
user_confirmed_intake: "确认需求",
|
||||
main_agent_decision_created: "总管决策",
|
||||
user_approved_next_step: "确认下一步",
|
||||
user_rejected_next_step: "拒绝下一步",
|
||||
worker_started: "执行单元开始",
|
||||
worker_completed: "执行单元完成",
|
||||
worker_needs_input: "执行单元提问",
|
||||
user_resolved_sidecar_questions: "回答挂载追问",
|
||||
user_accepted_artifact: "验收通过",
|
||||
user_rejected_artifact: "打回产物",
|
||||
user_requested_revision: "请求修订",
|
||||
programmatic_review_started: "程序验收开始",
|
||||
programmatic_review_passed: "程序验收通过",
|
||||
programmatic_review_failed: "程序验收失败",
|
||||
flow_completed: "流程结束",
|
||||
runtime_failed: "运行失败",
|
||||
};
|
||||
|
||||
const EFFECT_LABEL: Record<string, string> = {
|
||||
invoke_main_agent: "调用总管",
|
||||
run_worker: "启动执行单元",
|
||||
resume_worker: "恢复执行单元",
|
||||
run_programmatic_review: "程序验收",
|
||||
emit_message: "发消息",
|
||||
propose_next_creation_step: "提案下一步",
|
||||
};
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
read_blackboard: "读黑板",
|
||||
list_workers: "列执行单元",
|
||||
list_artifacts: "列产物",
|
||||
run_worker: "跑执行单元",
|
||||
ask_user: "问用户",
|
||||
review_blackboard: "审黑板",
|
||||
finish: "结束",
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
run_worker: "跑执行单元",
|
||||
ask_user: "问用户",
|
||||
review_blackboard: "审黑板",
|
||||
finish: "结束",
|
||||
};
|
||||
|
||||
let consoleUtf8Ready = false;
|
||||
|
||||
/** Windows 控制台切到 UTF-8,避免中文日志乱码。 */
|
||||
export function ensureConsoleUtf8(): void {
|
||||
if (consoleUtf8Ready) return;
|
||||
consoleUtf8Ready = true;
|
||||
if (process.platform !== "win32") return;
|
||||
try {
|
||||
// inherit 才会改当前窗口代码页;stdio ignore 只影响子进程
|
||||
execSync("chcp 65001 >NUL", { stdio: "inherit", shell: true, windowsHide: true });
|
||||
} catch {
|
||||
/* 无控制台(桌面隐藏启动)时忽略 */
|
||||
}
|
||||
try {
|
||||
process.stdout.setDefaultEncoding("utf8");
|
||||
process.stderr.setDefaultEncoding("utf8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeLogPath(): string {
|
||||
return path.join(getUserDataDir(), "logs", "runtime.log");
|
||||
}
|
||||
|
||||
let logFilePrepared = false;
|
||||
|
||||
function fileHasUtf8Bom(file: string): boolean {
|
||||
try {
|
||||
const fd = openSync(file, "r");
|
||||
const buf = Buffer.alloc(3);
|
||||
const n = readSync(fd, buf, 0, 3, 0);
|
||||
closeSync(fd);
|
||||
return n >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareLogFile(file: string): void {
|
||||
if (logFilePrepared) return;
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
if (existsSync(file) && statSync(file).size > 0 && !fileHasUtf8Bom(file)) {
|
||||
try {
|
||||
renameSync(file, `${file}.old`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!existsSync(file) || statSync(file).size === 0) {
|
||||
writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志(UTF-8)===\n`, "utf8");
|
||||
}
|
||||
logFilePrepared = true;
|
||||
}
|
||||
|
||||
function appendRuntimeLog(line: string): void {
|
||||
if (process.env.VITEST) return;
|
||||
try {
|
||||
const file = getRuntimeLogPath();
|
||||
prepareLogFile(file);
|
||||
try {
|
||||
if (statSync(file).size > MAX_LOG_BYTES) {
|
||||
renameSync(file, `${file}.1`);
|
||||
writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志(UTF-8)===\n`, "utf8");
|
||||
}
|
||||
} catch {
|
||||
/* rotate best-effort */
|
||||
}
|
||||
appendFileSync(file, `${line}\n`, "utf8");
|
||||
} catch {
|
||||
/* logging must not break the app */
|
||||
}
|
||||
}
|
||||
|
||||
function stamp(): string {
|
||||
return new Date().toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function labelPhase(phase: string): string {
|
||||
return PHASE_LABEL[phase as RuntimePhase] ?? phase;
|
||||
}
|
||||
|
||||
function labelWait(wait: string): string {
|
||||
if (wait === "-") return "无";
|
||||
return WAIT_LABEL[wait as WaitingReason["kind"]] ?? wait;
|
||||
}
|
||||
|
||||
export function labelCaller(caller: string | undefined): string {
|
||||
const who = caller?.trim() || "未知";
|
||||
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
||||
if (who === "intake_extract") return "需求抽取";
|
||||
if (who.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`;
|
||||
if (who === "unknown") return "未知";
|
||||
return who;
|
||||
}
|
||||
|
||||
export function labelLlmMode(kind: string): string {
|
||||
switch (kind) {
|
||||
case "complete":
|
||||
return "整段";
|
||||
case "stream":
|
||||
return "流式";
|
||||
case "tools":
|
||||
return "工具";
|
||||
case "tools-stream":
|
||||
return "工具流式";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
export function labelTool(name: string): string {
|
||||
return TOOL_LABEL[name] ?? name;
|
||||
}
|
||||
|
||||
export function labelAction(action: string): string {
|
||||
return ACTION_LABEL[action] ?? action;
|
||||
}
|
||||
|
||||
/** 服务端必要步骤:每次模型请求、状态迁移、创作步骤推进。 */
|
||||
export function debugLog(kind: "llm" | "state" | "step", line: string): void {
|
||||
ensureConsoleUtf8();
|
||||
const text = `[${stamp()} ${KIND_LABEL[kind]}] ${line}`;
|
||||
console.log(text);
|
||||
appendRuntimeLog(text);
|
||||
}
|
||||
|
||||
export function sessionSnap(session: RuntimeSession): {
|
||||
phase: string;
|
||||
wait: string;
|
||||
step: string;
|
||||
worker: string;
|
||||
} {
|
||||
return {
|
||||
phase: session.phase,
|
||||
wait: session.waitingReason?.kind ?? "-",
|
||||
step: session.currentStepId ?? "-",
|
||||
worker: session.currentWorkerId ?? "-",
|
||||
};
|
||||
}
|
||||
|
||||
function eventHint(event: RuntimeEvent): string {
|
||||
switch (event.type) {
|
||||
case "worker_started":
|
||||
case "worker_needs_input":
|
||||
return event.payload.workerId;
|
||||
case "main_agent_decision_created": {
|
||||
const d = event.payload.decision;
|
||||
const action = labelAction(d.action);
|
||||
return d.workerId ? `${action} ${d.workerId}` : action;
|
||||
}
|
||||
case "skill_selected":
|
||||
return event.payload.skill.name;
|
||||
case "user_accepted_artifact":
|
||||
case "user_rejected_artifact":
|
||||
return event.payload.artifactId.slice(0, 8);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function logStateChange(
|
||||
event: RuntimeEvent,
|
||||
before: ReturnType<typeof sessionSnap>,
|
||||
result: ApplyEventResult,
|
||||
): void {
|
||||
const after = sessionSnap(result.session);
|
||||
const bits = [EVENT_LABEL[event.type] ?? event.type];
|
||||
const hint = eventHint(event);
|
||||
if (hint) bits.push(hint);
|
||||
if (before.phase !== after.phase) {
|
||||
bits.push(`相位 ${labelPhase(before.phase)}→${labelPhase(after.phase)}`);
|
||||
} else {
|
||||
bits.push(`相位=${labelPhase(after.phase)}`);
|
||||
}
|
||||
if (before.wait !== after.wait) {
|
||||
bits.push(`等待 ${labelWait(before.wait)}→${labelWait(after.wait)}`);
|
||||
} else if (after.wait !== "-") {
|
||||
bits.push(`等待=${labelWait(after.wait)}`);
|
||||
}
|
||||
if (before.step !== after.step) bits.push(`步骤 ${before.step}→${after.step}`);
|
||||
if (before.worker !== after.worker) bits.push(`执行单元 ${before.worker}→${after.worker}`);
|
||||
const effects = result.effects.map((e) => {
|
||||
const name = EFFECT_LABEL[e.type] ?? e.type;
|
||||
return e.type === "run_worker" ? `${name} ${e.workerId}` : name;
|
||||
});
|
||||
if (effects.length) bits.push(`随后 ${effects.join(",")}`);
|
||||
if (result.error) bits.push(`出错 ${result.error.slice(0, 120)}`);
|
||||
debugLog("state", bits.join(" "));
|
||||
}
|
||||
@@ -23,48 +23,15 @@ function buildMainAgentSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是写作系统的编排器(Main Agent)。你的职责是调度执行单元(worker),而不是直接创作正文。
|
||||
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
|
||||
|
||||
规则:
|
||||
1. 你不能直接生成小说/文章正文。
|
||||
2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。
|
||||
3. 你只能建议下一步动作:ask_user、run_worker、create_temp_worker、review_blackboard、finish。
|
||||
4. 当信息不足时,使用 ask_user:assessment 是主内容(完备度评价);questions 挂在其下且用户可跳过;一次 1~2 题。
|
||||
5. 当需要执行任务时,使用 run_worker,只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。
|
||||
6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。
|
||||
7. run_worker 可选 workerContext:{ "roleId": "A" } 或 { "chance": { "op":"roll", "expression":"2d6" } }(机遇裁定走程序,勿让模型编随机)。
|
||||
8. 你不能把未验收内容当作事实。
|
||||
9. 向用户提问是 worker 的技能(ask_user tool),不是独立 worker。编排器只在调度层提问。
|
||||
10. blackboardIndex 只有 tag 索引,不含正文 content。
|
||||
|
||||
## 调度思维
|
||||
用需求正推:「用户需要 [具体体验/技能] → 调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
|
||||
禁止否定式路由:「某模式 / 背景形态 → 不需要某步骤 / 跳过某 worker」。
|
||||
|
||||
反例(禁止):「背景为单一角色,无需世界构建」「不是规则怪谈,跳过 write-rules」「默认上世界模拟套件」
|
||||
正例:「用户已选世界模拟器配方且要网恋对话 → design-flow 排出近期增量步骤(可调味、可后补;有编排参数的步骤须钉 params)→ 用户认可后反复 design-step;需要再补规则/实例 → 再 design-flow 追加同技能并写齐 params → 收成后若需开局 → opening-generator;用户手动进 play」
|
||||
- DAG 不存在或需要追加节点:调用 design-flow。
|
||||
- DAG 中已有下一个可执行节点:调用 design-step。
|
||||
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
|
||||
|
||||
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
||||
${workerLines}
|
||||
|
||||
## design 优先顺序
|
||||
- 尚无「设计.创作流程」→ **design-flow**(近期 horizon;status=open;未选配方则先请用户选)
|
||||
- 流程已验收且还有未完成步骤 → **design-step**(一次一步;技能由程序注入)
|
||||
- 已列步骤都验收但流程仍 **status=open** → 再 **design-flow**(追加反复步或 closed)
|
||||
- 禁止调度已废弃的 design-core / design-fixed / design-worker / design-refine
|
||||
- 终稿已 accept 且可用 opening-generator、尚无开场产物 → opening-generator
|
||||
- 进 play 由用户手动决定
|
||||
- **禁止**替用户猜测或改选配方
|
||||
- **禁止**一次编排排死全程固定 DAG
|
||||
|
||||
## play 优先顺序(lifecycleStage=play)
|
||||
- 严格按声明管线顺序调度(play_slots 展开序):**auditor(旁观维护)→ perspective? → gm(主世界层)→ narrator(转述)**
|
||||
- auditor / gm / perspective 的 acceptance 多为 continue:跑完立刻调度下一槽,不要中途 ask_user
|
||||
- 仅 narrator(或规格标明 review 的终稿槽)停下来等人看
|
||||
- chance 仅在需要真随机时按需 run_worker,不插入每轮固定序
|
||||
- slots.pendingSideEffectWorkers 非空时优先调度其中 workerId
|
||||
- **禁止**发明未声明的 play ref;**禁止**把旁观维护当成正文作者
|
||||
|
||||
输出必须是 JSON 对象,字段:
|
||||
{
|
||||
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
||||
@@ -110,40 +77,21 @@ export class MainAgent {
|
||||
|
||||
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
|
||||
const { session, blackboardIndex, availableWorkers } = context;
|
||||
const stage = String(session.slots?.uiLifecycleStage ?? "design");
|
||||
const pendingSide = session.slots?.pendingSideEffectWorkers;
|
||||
const creationTags = new Set([
|
||||
"设计.创作流程",
|
||||
"创作.当前步骤",
|
||||
"创作.当前单位",
|
||||
"创作.已验收单位",
|
||||
"用户.下一步意向",
|
||||
]);
|
||||
return JSON.stringify(
|
||||
{
|
||||
runtimePhase: session.phase,
|
||||
waitingReason: session.waitingReason,
|
||||
lifecycleStage: stage,
|
||||
flowId: session.flowId,
|
||||
currentStepId: session.currentStepId,
|
||||
currentWorkerId: session.currentWorkerId,
|
||||
acceptanceMode: session.acceptanceMode,
|
||||
slots: session.slots,
|
||||
pendingSideEffectWorkers: pendingSide ?? null,
|
||||
pendingDecision: session.pendingDecision
|
||||
? {
|
||||
id: session.pendingDecision.id,
|
||||
action: session.pendingDecision.action,
|
||||
reason: session.pendingDecision.reason,
|
||||
}
|
||||
: null,
|
||||
pendingArtifactId: session.pendingArtifactId,
|
||||
artifacts: session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
blackboardIndex,
|
||||
creationDagTags: blackboardIndex.filter((entry) =>
|
||||
creationTags.has(entry.tag),
|
||||
),
|
||||
availableWorkers,
|
||||
instruction:
|
||||
stage === "play"
|
||||
? "play:按声明序 auditor→perspective?→gm→narrator;continue 槽连跑;仅终稿 review 停;pendingSideEffectWorkers 优先;禁止未声明 ref。"
|
||||
: "根据当前状态决定下一步。尚无设计.创作流程 → design-flow;有未完成步骤 → design-step;steps 做完但 status=open → 再 design-flow;禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。",
|
||||
instruction: "读取创作 DAG,选择下一个创作节点。",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
+13
-71
@@ -6,6 +6,7 @@ import { isMainAgentTerminalTool } from "../types/tools.js";
|
||||
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
|
||||
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
|
||||
import type { MainAgentContext } from "./main-agent.js";
|
||||
import { debugLog } from "../log.js";
|
||||
|
||||
export type ToolLoopHandlers = {
|
||||
readBlackboard: (tags: string[]) => Record<string, string>;
|
||||
@@ -20,7 +21,7 @@ export type ToolLoopHandlers = {
|
||||
onToolCall?: (name: string, detail: string) => void;
|
||||
/** 思维链 / reasoning 流式增量(供 UI 实时展示) */
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
/** 一轮 LLM 调用结束后的完整思考文本 */
|
||||
/** 一轮 LLM 调用结束后的完整思维链(不含正文) */
|
||||
onThinkingDone?: (text: string) => void;
|
||||
};
|
||||
|
||||
@@ -40,71 +41,17 @@ function buildToolLoopSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。
|
||||
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
|
||||
|
||||
## 能力边界
|
||||
1. 你不能直接生成小说/文章正文。
|
||||
2. 你不能修改运行状态。
|
||||
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。
|
||||
4. 终止动作只能用 tool:ask_user、run_worker、review_blackboard、finish。
|
||||
- ask_user:**主内容是 assessment(内容完备度评价)**;questions 挂在其下且可被用户跳过。
|
||||
· assessment(必填):给用户看的评价正文。以【核心体验】为首;按需列维度(内容/人物关系/感官/背景规则/意义主题…),不必凑齐。每维写完备度%、已知、待探;待探用【】标互斥或可组合方向。
|
||||
· questions(必填数组,语义可选):只针对 assessment「待探」里想确认的点;一次 1~2 题。用户可不答、直接让你基于现有信息继续。prompt=明确题干;options=2~4 条**建议示范**;required 默认 false。
|
||||
· reason:调度短句,正推,勿塞长文。
|
||||
· 能推断就别问;已写在 用户.需求 里的不要重复问。
|
||||
5. run_worker 只传 workerId;inputTags/outputTags 由 Runtime 从 Worker Skill 读取。
|
||||
6. requiresApproval=true 时 run_worker 需用户确认后再执行。
|
||||
7. 不能把未验收产物当作已定事实。
|
||||
|
||||
## 调度思维(必须遵守)
|
||||
用 **需求正推**,禁止 **模式否定式** 表述。
|
||||
|
||||
**要这样思考(正推):**
|
||||
「用户需要 [具体体验/能力/产物] → 因此调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
|
||||
|
||||
**不要这样思考(反例):**
|
||||
「这是 xxx 模式 / 形态 → 不需要 xxx 步骤 / 跳过 xxx。」
|
||||
「背景为单一角色,无需世界构建。」
|
||||
「不是规则怪谈,跳过 write-rules。」
|
||||
|
||||
示例(好):
|
||||
- 「用户已选配方且要 1v1 网恋 → design-flow 排出近期增量步骤(可调味)→ 认可后反复 design-step;不够再追加。」
|
||||
- 「尚无设计.创作流程 → run_worker(design-flow);有未完成步骤 → design-step;steps 完但 status=open → 再 design-flow。」
|
||||
- 「运行规格已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」
|
||||
|
||||
示例(坏):
|
||||
- 「调度 design-core / design-fixed(已废弃)。」
|
||||
- 「一次 design-flow 排死全程固定 DAG。」
|
||||
- 「跳过流程编排直接写满运行规格。」
|
||||
- 「默认先上世界模拟全套再问用户要什么。」
|
||||
- 「替用户改选 / 猜测配方。」
|
||||
|
||||
在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。
|
||||
|
||||
## design 阶段提示
|
||||
- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizon;status=open;未选则先请用户选)。
|
||||
- 流程已验收、还有未完成步骤 → **design-step**(一次一步)。
|
||||
- 已列步骤全验收但 **status=open** → 再 **design-flow**(追加生成规则/具体实例等并钉齐 params,或设 closed)。
|
||||
- 有编排参数的步骤缺必填 params → 先让 design-flow askUser,不要空壳跑 design-step。
|
||||
- 禁止 run_worker(design-core|design-fixed|design-worker|design-refine)——已废弃。
|
||||
- 不要重复问用户「想做什么」——意图已在 用户.需求。
|
||||
- 运行规格已 accept,且可用 worker 含 opening-generator,尚无开场产物 → opening-generator。
|
||||
- 进 play 由用户手动决定。
|
||||
- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。
|
||||
- **禁止**替用户猜测或改选配方。
|
||||
- **禁止**一次编排排死全程固定长链。
|
||||
|
||||
## play 阶段提示(lifecycleStage=play 时优先)
|
||||
- 管线顺序:**auditor(旁观维护)→ perspective? → gm(主世界层)→ narrator**。
|
||||
- auditor/gm/perspective 多为 continue:写完立刻 run 下一槽,不要中途 ask_user。
|
||||
- 只在 narrator(或 review 终稿槽)停给人看。
|
||||
- chance 按需;pendingSideEffectWorkers 非空则优先调度。
|
||||
- 禁止未声明 play ref;旁观维护默认空操作,不是正文作者。
|
||||
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
|
||||
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
|
||||
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
|
||||
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
|
||||
|
||||
当前 skill 可用 worker:
|
||||
${workerLines}
|
||||
|
||||
信息不足时可多次 read_blackboard;一旦调用终止 tool,本轮循环结束。`;
|
||||
选定节点后立即调用 run_worker。`;
|
||||
}
|
||||
|
||||
async function completeToolsPreferStream(
|
||||
@@ -172,14 +119,9 @@ function assistantMessageFromToolCalls(
|
||||
};
|
||||
}
|
||||
|
||||
function emitThinkingDone(handlers: ToolLoopHandlers, parts: {
|
||||
reasoning?: string;
|
||||
content?: string | null;
|
||||
}): void {
|
||||
const reasoning = parts.reasoning?.trim() ?? "";
|
||||
const content = parts.content?.trim() ?? "";
|
||||
const combined = [reasoning, content].filter(Boolean).join("\n\n").trim();
|
||||
if (combined) handlers.onThinkingDone?.(combined);
|
||||
function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void {
|
||||
const text = reasoning?.trim() ?? "";
|
||||
if (text) handlers.onThinkingDone?.(text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,12 +144,12 @@ export async function runMainAgentToolLoop(
|
||||
const toolTrace: string[] = [];
|
||||
const streamCallbacks: StreamCallbacks = {
|
||||
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => handlers.onThinkingDelta?.(delta),
|
||||
};
|
||||
|
||||
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
|
||||
debugLog("llm", `总管循环 第${iteration}轮`);
|
||||
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
|
||||
emitThinkingDone(handlers, result);
|
||||
emitThinkingDone(handlers, result.reasoning);
|
||||
|
||||
if (result.toolCalls.length === 0) {
|
||||
if (result.content?.trim()) {
|
||||
|
||||
+1
-148
@@ -22,118 +22,11 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_workers",
|
||||
description: "列出当前 skill 可调度的 worker id 与说明。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_artifacts",
|
||||
description: "列出当前会话 worker 产物(id、workerId、status、summary)。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ask_user",
|
||||
description:
|
||||
"向用户追问。assessment 是给用户看的主内容(完备度评价);questions 挂在其下,用户可跳过并请你基于现有信息继续。将暂停等待用户。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: {
|
||||
type: "string",
|
||||
description:
|
||||
"调度层短理由(正推:用户需要澄清 X → 因此追问)。勿把长文评价写这里。",
|
||||
},
|
||||
assessment: {
|
||||
type: "string",
|
||||
description:
|
||||
"给用户看的内容完备度评价(Markdown)。以【核心体验】为首要;按需列维度(不必凑齐),每维:完备度%、已知、待探。待探用【】标出互斥/可组合方向。评价只服务「用户想要什么」。",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description:
|
||||
"兼容旧字段:等同 assessment。优先传 assessment。",
|
||||
},
|
||||
questions: {
|
||||
type: "array",
|
||||
description:
|
||||
"挂在 assessment 下的可选追问(用户可不答)。基于「待探」出题,一次 1~2 题。prompt=明确选择题干;options=建议示范(可改写后采用)。",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "稳定 id,如 q1 / stance",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description:
|
||||
"题干:引导用户选定一种详细体验倾向(例:「你更倾向于哪种皇帝的享受?」)。",
|
||||
},
|
||||
options: {
|
||||
type: "array",
|
||||
description:
|
||||
"2~4 个建议示范。label 写完整可采纳文案(可先一句场景钩子再点题),不是标签词。",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "A/B/C…",
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
description:
|
||||
"建议正文:用户点选后可直接当答案,也可改写。例:「冰冷的、主宰一切的权力感——九重宫阙一言定生死…」",
|
||||
},
|
||||
editable: {
|
||||
type: "boolean",
|
||||
description: "默认 true:点文案可改写,点字母才选中",
|
||||
},
|
||||
},
|
||||
required: ["label"],
|
||||
},
|
||||
},
|
||||
allowOther: {
|
||||
type: "boolean",
|
||||
description: "默认 true:允许 Other 自拟",
|
||||
},
|
||||
required: {
|
||||
type: "boolean",
|
||||
description: "默认 false(可选追问);仅关键阻塞题才 true",
|
||||
},
|
||||
},
|
||||
required: ["prompt", "options"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["reason", "assessment", "questions"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "run_worker",
|
||||
description:
|
||||
"调度 worker。常规只传 workerId。调度 chance(机遇裁定)时传 chance 请求对象(程序掷骰/比点/抽签,禁止让模型编随机)。",
|
||||
description: "执行选中的下一个创作节点。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -143,50 +36,10 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: "boolean",
|
||||
description: "true 时需用户确认后才执行",
|
||||
},
|
||||
roleId: {
|
||||
type: "string",
|
||||
description: "role-decide 等 worker 的当前角色 id",
|
||||
},
|
||||
chance: {
|
||||
type: "object",
|
||||
description:
|
||||
"仅 workerId=chance:{ op:'roll'|'compare'|'draw'|'pick', ... }。例 roll:{expression:'2d6+1'};draw:{pool:['a','b'],count:1}",
|
||||
},
|
||||
},
|
||||
required: ["workerId", "reason", "requiresApproval"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "review_blackboard",
|
||||
description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string" },
|
||||
summary: { type: "string", description: "给用户看的概况说明" },
|
||||
},
|
||||
required: ["reason", "summary"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "finish",
|
||||
description: "正常结束当前创作流程。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string" },
|
||||
},
|
||||
required: ["reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+231
-37
@@ -26,7 +26,10 @@ import {
|
||||
readIntakeValues,
|
||||
synthesizeDemandText,
|
||||
} from "../intake/intake.js";
|
||||
import { normalizeQuestions } from "../skills/question-protocol.js";
|
||||
import {
|
||||
isModuleOpeningQuestions,
|
||||
normalizeQuestions,
|
||||
} from "../skills/question-protocol.js";
|
||||
import type { QuestionItem } from "../types/questions.js";
|
||||
|
||||
function nowIso(): string {
|
||||
@@ -119,6 +122,67 @@ function updateArtifact(
|
||||
};
|
||||
}
|
||||
|
||||
/** 最近一次被拒 / 待修订的产物(用于 revision 等待态补交意见后重跑) */
|
||||
function findRevisionTargetArtifact(
|
||||
session: RuntimeSession,
|
||||
): ArtifactRecord | undefined {
|
||||
if (session.pendingArtifactId) {
|
||||
const pending = findArtifact(session, session.pendingArtifactId);
|
||||
if (pending) return pending;
|
||||
}
|
||||
for (let i = session.artifacts.length - 1; i >= 0; i -= 1) {
|
||||
const a = session.artifacts[i];
|
||||
if (a.status === "revision_requested" || a.status === "rejected") {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已有修改意见 → 写入「用户.修订说明」并立刻重跑同一 worker。
|
||||
* 语义:在已有产物上按意见改(上下文会注入待改底稿),不是推倒重做。
|
||||
* 对应验收底栏:看产物 → 输入意见 →「按意见修改」。
|
||||
*/
|
||||
function beginRevisionRerun(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
artifact: ArtifactRecord,
|
||||
instruction: string,
|
||||
): ApplyEventResult {
|
||||
const trimmed = instruction.trim();
|
||||
const slots: Record<string, unknown> = {
|
||||
...session.slots,
|
||||
"用户.修订说明": trimmed,
|
||||
"用户.最新输入": trimmed,
|
||||
revisionInstruction: trimmed,
|
||||
// 本次重跑的底稿;重跑写不出新版时只许退回这一份
|
||||
revisionTargetArtifactId: artifact.id,
|
||||
};
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "revision_requested" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({
|
||||
...next,
|
||||
slots,
|
||||
pendingArtifactId: undefined,
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: artifact.workerId,
|
||||
currentStepId: artifact.stepId ?? session.currentStepId,
|
||||
}),
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: artifact.workerId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建 idle 状态的新会话 */
|
||||
export function createSession(
|
||||
presetId: string,
|
||||
@@ -154,6 +218,7 @@ export function getAllowedEvents(
|
||||
"worker_started",
|
||||
"worker_completed",
|
||||
"worker_needs_input",
|
||||
"worker_revision_produced_nothing",
|
||||
"programmatic_review_started",
|
||||
"programmatic_review_passed",
|
||||
"programmatic_review_failed",
|
||||
@@ -204,7 +269,8 @@ export function canApplyEvent(
|
||||
reason?.kind === "intake" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision"
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "next_intent"
|
||||
);
|
||||
case "user_confirmed_intake":
|
||||
return reason?.kind === "intake";
|
||||
@@ -256,6 +322,14 @@ function handleWorkerCompleted(
|
||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||
let next = appendHistory(session, event);
|
||||
|
||||
if (next.slots.revisionTargetArtifactId) {
|
||||
// 已写出新版,旧底稿不再是退回目标
|
||||
next = {
|
||||
...next,
|
||||
slots: { ...next.slots, revisionTargetArtifactId: undefined },
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "user_confirmed") {
|
||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||
return {
|
||||
@@ -454,6 +528,62 @@ export function applyEvent(
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "next_intent") {
|
||||
if (text) {
|
||||
slots["用户.下一步意向"] = text;
|
||||
slots["用户.最新输入"] = text;
|
||||
}
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
resumeContext: undefined,
|
||||
},
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(running(next)),
|
||||
effects: [{ type: "propose_next_creation_step" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "revision") {
|
||||
const instruction =
|
||||
text.trim() || session.waitingReason.instruction?.trim() || "";
|
||||
if (!instruction) {
|
||||
return {
|
||||
session: waiting(touch(appendHistory({ ...session, slots }, event)), {
|
||||
kind: "revision",
|
||||
instruction: session.waitingReason.instruction,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: "请说明要改哪里,再发送。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const target = findRevisionTargetArtifact(session);
|
||||
if (target) {
|
||||
return beginRevisionRerun(session, event, target, instruction);
|
||||
}
|
||||
slots["用户.修订说明"] = instruction;
|
||||
slots["用户.最新输入"] = instruction;
|
||||
slots.revisionInstruction = instruction;
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, slots, pendingArtifactId: undefined },
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "worker_questions") {
|
||||
slots["用户.worker答复"] = text;
|
||||
if (text) slots["用户.最新输入"] = text;
|
||||
@@ -660,6 +790,7 @@ export function applyEvent(
|
||||
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
|
||||
questions: normalized,
|
||||
};
|
||||
const openingAsk = isModuleOpeningQuestions(normalized);
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory(
|
||||
@@ -675,7 +806,10 @@ export function applyEvent(
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`,
|
||||
// 默认问题正文由说话面 openingGuide 展示,勿再整段塞进调度消息
|
||||
message: openingAsk
|
||||
? `[Worker] ${event.payload.workerId} · 默认问题(请在主栏按引导作答)`
|
||||
: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -690,16 +824,10 @@ export function applyEvent(
|
||||
typeof event.payload.assessment === "string"
|
||||
? event.payload.assessment.trim()
|
||||
: "";
|
||||
const effects = [...result.effects];
|
||||
if (sidecar?.length) {
|
||||
effects.push({
|
||||
type: "emit_message",
|
||||
message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`,
|
||||
});
|
||||
}
|
||||
// 追问与产物同一次产出:只挂 review_artifact,勿再 emit 独立「可选追问」消息
|
||||
//(否则历史里会拆成 worker_output + worker_questions 两段)
|
||||
return {
|
||||
...result,
|
||||
effects,
|
||||
session: waiting(result.session, {
|
||||
kind: "review_artifact",
|
||||
artifactId: result.session.pendingArtifactId,
|
||||
@@ -711,6 +839,42 @@ export function applyEvent(
|
||||
return result;
|
||||
}
|
||||
|
||||
case "worker_revision_produced_nothing": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const retryQuestions = normalizeQuestions(event.payload.questions).map(
|
||||
(q) => ({ ...q, required: false }),
|
||||
);
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "under_review" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(
|
||||
{
|
||||
...next,
|
||||
slots: { ...next.slots, revisionTargetArtifactId: undefined },
|
||||
pendingArtifactId: artifact.id,
|
||||
currentWorkerId: undefined,
|
||||
},
|
||||
{
|
||||
kind: "review_artifact",
|
||||
artifactId: artifact.id,
|
||||
questions: retryQuestions.length ? retryQuestions : undefined,
|
||||
},
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message:
|
||||
"[系统] 这次按意见重写没能产出可验收的新版本,已退回上一版产物:可补充说明再改,或直接接受上一版。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_resolved_sidecar_questions": {
|
||||
const reason = session.waitingReason;
|
||||
if (reason?.kind !== "review_artifact") {
|
||||
@@ -745,22 +909,43 @@ export function applyEvent(
|
||||
}
|
||||
const slots: Record<string, unknown> = { ...session.slots };
|
||||
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
|
||||
if (artifact.outputTags.some((tag) => tag === "设计.worker集")) {
|
||||
const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集");
|
||||
if (isFinalSet) {
|
||||
slots.designInstanceReady = true;
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({
|
||||
...next,
|
||||
slots,
|
||||
pendingArtifactId: undefined,
|
||||
pendingDecision: undefined,
|
||||
const cleared = {
|
||||
...next,
|
||||
slots,
|
||||
pendingArtifactId: undefined,
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
};
|
||||
// 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑)
|
||||
const gateNextIntent =
|
||||
!isFinalSet &&
|
||||
(artifact.workerId === "design-step" ||
|
||||
artifact.workerId === "design-flow");
|
||||
if (gateNextIntent) {
|
||||
return {
|
||||
session: waiting(cleared, {
|
||||
kind: "next_intent",
|
||||
afterWorkerId: artifact.workerId,
|
||||
}),
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message:
|
||||
"这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: touch(running(cleared)),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
@@ -770,19 +955,21 @@ export function applyEvent(
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const reason = event.payload.reason?.trim() ?? "";
|
||||
// 验收态已写意见 → 在现有产物上按意见改;无意见才进入 revision 等待补交
|
||||
if (reason) {
|
||||
return beginRevisionRerun(session, event, artifact, reason);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "rejected" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
session: waiting(next, { kind: "revision" }),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。",
|
||||
message: "已保留当前产物,请说明要改哪里。",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -800,15 +987,26 @@ export function applyEvent(
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
const instruction = event.payload.instruction?.trim() ?? "";
|
||||
const artifactId =
|
||||
event.payload.artifactId ?? session.pendingArtifactId;
|
||||
const artifact = artifactId
|
||||
? findArtifact(session, artifactId)
|
||||
: findRevisionTargetArtifact(session);
|
||||
if (instruction && artifact) {
|
||||
return beginRevisionRerun(session, event, artifact, instruction);
|
||||
}
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory({ ...session, pendingArtifactId: undefined }, event),
|
||||
{ kind: "revision", instruction: event.payload.instruction },
|
||||
appendHistory(session, event),
|
||||
{ kind: "revision", instruction: instruction || undefined },
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `返工请求:${event.payload.instruction}`,
|
||||
message: instruction
|
||||
? `修改意见:${instruction}`
|
||||
: "请说明要改哪里(在现有产物上改)。",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -840,20 +1038,16 @@ export function applyEvent(
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "revision_requested" }),
|
||||
event,
|
||||
);
|
||||
const reason = event.payload.reason?.trim() || "程序验收失败";
|
||||
const result = beginRevisionRerun(session, event, artifact, reason);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
...result,
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `程序验收失败:${event.payload.reason}`,
|
||||
message: `程序验收未过,按意见在现有产物上改:${reason}`,
|
||||
},
|
||||
...result.effects,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+430
-25
@@ -57,18 +57,36 @@ import {
|
||||
import {
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_MODULE_OPENING_TAG,
|
||||
CREATION_MODULE_OPENING_STATE_TAG,
|
||||
SLOT_CREATION_MODULE_OPENING_STATE,
|
||||
loadModuleCatalog,
|
||||
mergeCreationFlowPreservingAccepted,
|
||||
nextPendingStep,
|
||||
parseCreationFlow,
|
||||
parseModuleOpeningState,
|
||||
patchCreationFlowStepParams,
|
||||
pickRecordedStepId,
|
||||
resolveDesignStepBinding,
|
||||
shouldSkipModuleOpening,
|
||||
isProgressPointerTag,
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
stringifyModuleOpeningState,
|
||||
type CreationFlowStepParams,
|
||||
type ModuleParamSpec,
|
||||
} from "../skills/creation-flow.js";
|
||||
import { normalizeQuestions } from "../skills/question-protocol.js";
|
||||
import {
|
||||
appendAskedQuestions,
|
||||
dropAlreadyAskedQuestions,
|
||||
MODULE_OPENING_QUESTION_ID,
|
||||
normalizeQuestions,
|
||||
parseAskedQuestions,
|
||||
SLOT_ASKED_QUESTIONS,
|
||||
} from "../skills/question-protocol.js";
|
||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||
import {
|
||||
executeChance,
|
||||
resolveChanceRequest,
|
||||
@@ -363,10 +381,18 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
|
||||
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
|
||||
/** applyEvent 并打状态日志(dispatch / 总管决策共用) */
|
||||
private commitEvent(event: RuntimeEvent): ApplyEventResult {
|
||||
const before = sessionSnap(this.session);
|
||||
const result = applyEvent(this.session, event);
|
||||
this.session = result.session;
|
||||
logStateChange(event, before, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
|
||||
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
|
||||
const result = this.commitEvent(event);
|
||||
|
||||
if (
|
||||
event.type === "user_submitted_input" ||
|
||||
@@ -416,14 +442,26 @@ export class PhaseRuntime {
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
const revision = session.slots.revisionInstruction;
|
||||
const revision =
|
||||
(typeof session.slots["用户.修订说明"] === "string" &&
|
||||
session.slots["用户.修订说明"]) ||
|
||||
(typeof session.slots.revisionInstruction === "string" &&
|
||||
session.slots.revisionInstruction);
|
||||
if (typeof revision === "string" && revision.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.修改说明",
|
||||
tag: "用户.修订说明",
|
||||
content: revision.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
const nextIntent = session.slots["用户.下一步意向"];
|
||||
if (typeof nextIntent === "string" && nextIntent.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.下一步意向",
|
||||
content: nextIntent.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,10 +528,15 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户确认 pendingDecision(approve_step) */
|
||||
async approve(): Promise<RuntimeSession> {
|
||||
/** 用户确认 pendingDecision(approve_step);可顺带钉死下一步 params */
|
||||
async approve(stepParams?: CreationFlowStepParams): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待确认的决策");
|
||||
if (stepParams && Object.keys(stepParams).length > 0) {
|
||||
this.applyProposedStepParams(stepParams);
|
||||
}
|
||||
const readyErr = this.assertProposedStepReady();
|
||||
if (readyErr) throw new Error(readyErr);
|
||||
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
|
||||
return this.session;
|
||||
}
|
||||
@@ -577,7 +620,7 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingArtifact,进入 revision */
|
||||
/** 用户拒绝 pendingArtifact:有意见则立刻按「用户.修订说明」在现有产物上改 */
|
||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) throw new Error("当前没有待拒绝的产物");
|
||||
@@ -588,12 +631,39 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按意见重跑却没写出新产物:退回**本次修订的那一份**产物验收,重试追问改挂为可选。
|
||||
* 只认 beginRevisionRerun 钉下的底稿;否则会把无关的旧产物(如上一轮流程编排)
|
||||
* 错误地挂回验收面。首跑无产物仍走阻塞追问。
|
||||
* @returns true = 已退回验收,调用方应 return
|
||||
*/
|
||||
private async restoreRevisionTargetForReview(
|
||||
workerId: string,
|
||||
questions: import("../types/questions.js").QuestionItem[] | string[],
|
||||
): Promise<boolean> {
|
||||
const targetId = this.session.slots.revisionTargetArtifactId;
|
||||
if (typeof targetId !== "string" || !targetId) return false;
|
||||
const target = this.session.artifacts.find(
|
||||
(a) =>
|
||||
a.id === targetId &&
|
||||
a.workerId === workerId &&
|
||||
a.status === "revision_requested",
|
||||
);
|
||||
if (!target) return false;
|
||||
await this.dispatch({
|
||||
type: "worker_revision_produced_nothing",
|
||||
payload: { artifactId: target.id, questions },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */
|
||||
async workerAsk(
|
||||
questions: import("../types/questions.js").QuestionItem[] | string[],
|
||||
): Promise<RuntimeSession> {
|
||||
const workerId = this.session.currentWorkerId;
|
||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||
this.rememberAskedQuestions(normalizeQuestions(questions));
|
||||
await this.dispatch({
|
||||
type: "worker_needs_input",
|
||||
payload: { workerId, stepId: this.session.currentStepId, questions },
|
||||
@@ -601,6 +671,35 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private askedQuestionHistory() {
|
||||
return parseAskedQuestions(this.session.slots[SLOT_ASKED_QUESTIONS]);
|
||||
}
|
||||
|
||||
private rememberAskedQuestions(
|
||||
questions: readonly import("../types/questions.js").QuestionItem[],
|
||||
): void {
|
||||
if (!questions.length) return;
|
||||
const next = appendAskedQuestions(this.askedQuestionHistory(), questions);
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [SLOT_ASKED_QUESTIONS]: next },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂在验收态的可跳过追问:本局问过的不再问一次。
|
||||
* 编排层常把上一步已问过的体验题再抛一遍,用户会读成「又要重做那一步」。
|
||||
*/
|
||||
private freshSidecarQuestions(
|
||||
questions: readonly import("../types/questions.js").QuestionItem[] | undefined,
|
||||
): import("../types/questions.js").QuestionItem[] | undefined {
|
||||
if (!questions?.length) return undefined;
|
||||
const fresh = dropAlreadyAskedQuestions(questions, this.askedQuestionHistory());
|
||||
if (!fresh.length) return undefined;
|
||||
this.rememberAskedQuestions(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收态下作答或跳过挂载追问:不离开 review_artifact。
|
||||
* answersText 缺省 = 跳过。
|
||||
@@ -662,6 +761,13 @@ export class PhaseRuntime {
|
||||
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
|
||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||
for (const effect of effects) {
|
||||
if (effect.type === "run_worker") {
|
||||
debugLog("step", `启动执行单元 ${effect.workerId}`);
|
||||
} else if (effect.type === "propose_next_creation_step") {
|
||||
debugLog("step", "提案下一步");
|
||||
} else if (effect.type === "resume_worker") {
|
||||
debugLog("step", `恢复执行单元 ${this.session.resumeContext?.workerId ?? "未知"}`);
|
||||
}
|
||||
switch (effect.type) {
|
||||
case "emit_message":
|
||||
this.onMessage(effect.message);
|
||||
@@ -705,10 +811,209 @@ export class PhaseRuntime {
|
||||
case "run_programmatic_review":
|
||||
await this.runProgrammaticReview(effect.artifactId);
|
||||
break;
|
||||
case "propose_next_creation_step":
|
||||
await this.proposeNextCreationStep();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
|
||||
*/
|
||||
private async proposeNextCreationStep(): Promise<void> {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const accepted = parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
const pending = nextPendingStep(flow, accepted);
|
||||
if (!pending || !flow) {
|
||||
debugLog("step", "暂无下一步,交给总管");
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: undefined,
|
||||
},
|
||||
};
|
||||
if (this.mainAgent) {
|
||||
await this.runMainAgent();
|
||||
} else {
|
||||
this.onMessage(
|
||||
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.getActiveSkill();
|
||||
let paramSpecs: ModuleParamSpec[] = [];
|
||||
let declaration = "";
|
||||
if (active?.name) {
|
||||
try {
|
||||
const skill = await loadSkill(active.name);
|
||||
if (skill.skillPackRoot) {
|
||||
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
||||
const mod = catalog?.modules.find((m) => m.name === pending.name);
|
||||
paramSpecs = mod?.params ?? [];
|
||||
declaration = mod?.declaration?.trim() || "";
|
||||
}
|
||||
} catch {
|
||||
/* catalog optional */
|
||||
}
|
||||
}
|
||||
|
||||
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
|
||||
|
||||
const params = pending.params ? { ...pending.params } : {};
|
||||
const paramsMissing = paramSpecs
|
||||
.filter((p) => p.required)
|
||||
.map((p) => p.key)
|
||||
.filter((key) => {
|
||||
const v = params[key];
|
||||
return v == null || (typeof v === "string" && !v.trim());
|
||||
});
|
||||
|
||||
const intent =
|
||||
typeof this.session.slots["用户.下一步意向"] === "string"
|
||||
? String(this.session.slots["用户.下一步意向"]).trim()
|
||||
: "";
|
||||
const paramHint =
|
||||
Object.keys(params).length > 0
|
||||
? `;参数 ${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" · ")}`
|
||||
: paramsMissing.length
|
||||
? `;待钉参数:${paramsMissing.join("、")}`
|
||||
: "";
|
||||
const reason = [
|
||||
`下一步:${pending.name}`,
|
||||
declaration ? `—— ${declaration}` : "",
|
||||
paramHint,
|
||||
intent ? `(你的意向:${intent})` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
const snapshot = {
|
||||
stepId: pending.id,
|
||||
name: pending.name,
|
||||
declaration: declaration || undefined,
|
||||
params,
|
||||
paramsMissing,
|
||||
paramSpecs: paramSpecs.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
required: Boolean(p.required),
|
||||
hint: p.hint,
|
||||
})),
|
||||
intent: intent || undefined,
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
},
|
||||
};
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: "design-step",
|
||||
reason,
|
||||
requiresApproval: true,
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
private applyProposedStepParams(params: CreationFlowStepParams): void {
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
|
||||
: null) ||
|
||||
this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
|
||||
if (!raw?.trim()) return;
|
||||
let snap: {
|
||||
stepId?: string;
|
||||
paramSpecs?: Array<{ key: string; required?: boolean }>;
|
||||
};
|
||||
try {
|
||||
snap = JSON.parse(raw) as typeof snap;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stepId = typeof snap.stepId === "string" ? snap.stepId.trim() : "";
|
||||
if (!stepId) return;
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return;
|
||||
const next = patchCreationFlowStepParams(flow, stepId, params);
|
||||
const content = stringifyCreationFlow(next);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
const paramsMissing = (snap.paramSpecs ?? [])
|
||||
.filter((p) => p.required)
|
||||
.map((p) => p.key)
|
||||
.filter((key) => {
|
||||
const v = params[key];
|
||||
return v == null || (typeof v === "string" && !v.trim());
|
||||
});
|
||||
const snapshot = {
|
||||
...snap,
|
||||
params,
|
||||
paramsMissing,
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_FLOW_TAG]: content,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private assertProposedStepReady(): string | null {
|
||||
const decision = this.session.pendingDecision;
|
||||
if (decision?.workerId !== "design-step") return null;
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
|
||||
: null) ||
|
||||
this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
|
||||
if (!raw?.trim()) return null;
|
||||
try {
|
||||
const snap = JSON.parse(raw) as { paramsMissing?: string[] };
|
||||
if (snap.paramsMissing?.length) {
|
||||
return `请先补齐必填参数:${snap.paramsMissing.join("、")}`;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getInstanceWorkerDeclaration() {
|
||||
return buildInstanceWorkerDeclaration(
|
||||
this.session,
|
||||
@@ -840,24 +1145,50 @@ export class PhaseRuntime {
|
||||
|
||||
if (result.askUser?.length && Object.keys(result.outputs).length === 0) {
|
||||
// 无产物:阻塞追问(信息不足,必须补)
|
||||
if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) {
|
||||
return;
|
||||
}
|
||||
await this.workerAsk(result.askUser);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
workerId === "design-step" &&
|
||||
Object.keys(result.outputs).length === 0
|
||||
) {
|
||||
const retryQuestions = result.askUser?.length
|
||||
? result.askUser
|
||||
: [
|
||||
{
|
||||
id: "design-step-retry",
|
||||
prompt:
|
||||
"这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。",
|
||||
allowOther: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
if (await this.restoreRevisionTargetForReview(workerId, retryQuestions)) {
|
||||
return;
|
||||
}
|
||||
await this.workerAsk(retryQuestions);
|
||||
return;
|
||||
}
|
||||
|
||||
slots = { ...this.session.slots };
|
||||
const writtenTags: string[] = [];
|
||||
for (const [tag, content] of Object.entries(result.outputs)) {
|
||||
if (isProgressPointerTag(tag)) continue;
|
||||
const written = this.writeWorkerTagContent(tag, content, workerId);
|
||||
slots[tag] = written;
|
||||
writtenTags.push(tag);
|
||||
}
|
||||
this.session = { ...this.session, slots };
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags:
|
||||
Object.keys(result.outputs).length > 0
|
||||
? Object.keys(result.outputs)
|
||||
: worker.outputTags,
|
||||
outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter(
|
||||
(t) => !isProgressPointerTag(t),
|
||||
),
|
||||
summary: result.summary,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
@@ -871,7 +1202,7 @@ export class PhaseRuntime {
|
||||
payload: {
|
||||
artifactId: artifact.id,
|
||||
// 有产物时 askUser 挂到验收态,不阻断 Accept
|
||||
questions: result.askUser?.length ? result.askUser : undefined,
|
||||
questions: this.freshSidecarQuestions(result.askUser),
|
||||
assessment: result.askAssessment?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
@@ -1054,6 +1385,22 @@ export class PhaseRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tag === CREATION_FLOW_TAG) {
|
||||
const merged = mergeCreationFlowPreservingAccepted({
|
||||
prevRaw: this.blackboard.getContentByTag(tag),
|
||||
nextRaw: toWrite,
|
||||
acceptedStepIds: parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
),
|
||||
});
|
||||
toWrite = merged.raw;
|
||||
if (merged.restored.length) {
|
||||
this.onMessage(
|
||||
`[流程编排] 已验收步骤按原样保留:${merged.restored.join("、")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: toWrite,
|
||||
@@ -1252,6 +1599,48 @@ export class PhaseRuntime {
|
||||
|
||||
if (phase === "answered") return false;
|
||||
|
||||
// 配方开局模块 + 用户首句已在「用户.需求」→ 默认视为 opening 已做完,直接进 LLM
|
||||
const demand = (
|
||||
this.blackboard.getContentByTag("用户.需求") ??
|
||||
this.session.slots["用户.需求"] ??
|
||||
""
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (
|
||||
shouldSkipModuleOpening({
|
||||
demand,
|
||||
dependsOn: binding.step.depends_on,
|
||||
phase,
|
||||
})
|
||||
) {
|
||||
state[stepKey] = "answered";
|
||||
const raw = stringifyModuleOpeningState(state);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_MODULE_OPENING_STATE_TAG,
|
||||
content: raw,
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_MODULE_OPENING_TAG,
|
||||
content: "(已跳过默认问题:用户首句见「用户.需求」)",
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[SLOT_CREATION_MODULE_OPENING_STATE]: raw,
|
||||
[CREATION_MODULE_OPENING_TAG]:
|
||||
"(已跳过默认问题:用户首句见「用户.需求」)",
|
||||
},
|
||||
};
|
||||
this.onMessage(
|
||||
`[系统] ${binding.module.name} · 已坐在配方开局步,首句见「用户.需求」,跳过默认问题`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phase === "shown" && this.session.resumeContext) {
|
||||
// 用户刚答完默认问题 → 标记已答,继续走 LLM
|
||||
state[stepKey] = "answered";
|
||||
@@ -1315,15 +1704,13 @@ export class PhaseRuntime {
|
||||
|
||||
const questions = normalizeQuestions([
|
||||
{
|
||||
id: "module-opening",
|
||||
id: MODULE_OPENING_QUESTION_ID,
|
||||
prompt: binding.opening.trim(),
|
||||
allowOther: true,
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
this.onMessage(
|
||||
`[Worker] ${binding.module.name} · 默认问题(程序):\n${binding.opening.trim()}`,
|
||||
);
|
||||
// 正文由说话面 openingGuide 展示(对齐美学纲领);workerAsk 只发短调度句
|
||||
await this.workerAsk(questions);
|
||||
return true;
|
||||
}
|
||||
@@ -1356,6 +1743,8 @@ export class PhaseRuntime {
|
||||
|
||||
if (!current) current = "flow";
|
||||
|
||||
debugLog("step", `钉住当前步骤 ${workerId} → ${current}`);
|
||||
|
||||
const anchorAt = new Date().toISOString();
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_UNIT_TAG,
|
||||
@@ -1386,17 +1775,29 @@ export class PhaseRuntime {
|
||||
const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
|
||||
const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim();
|
||||
const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim();
|
||||
const unitId =
|
||||
(artifact.workerId === "design-step" ? fromStep : "") ||
|
||||
fromBoard ||
|
||||
fromSlot ||
|
||||
fromSummary ||
|
||||
"";
|
||||
// 接受工作流计划 ≠ 某一步做完;「flow」不得写入已验收步骤
|
||||
if (artifact.workerId === "design-flow") return;
|
||||
const prev = parseAcceptedUnits(
|
||||
slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
const unitId =
|
||||
artifact.workerId === "design-step"
|
||||
? pickRecordedStepId({
|
||||
flow: parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
),
|
||||
alreadyAccepted: prev,
|
||||
pinnedUnitId: fromSlot || fromBoard,
|
||||
writtenCurrentStep: fromStep,
|
||||
summaryHint: fromSummary,
|
||||
}) ?? ""
|
||||
: fromBoard || fromSlot || fromSummary || "";
|
||||
if (!unitId || unitId === "flow") return;
|
||||
const next = unitId && !prev.includes(unitId) ? [...prev, unitId] : prev;
|
||||
if (next !== prev) {
|
||||
debugLog("step", `验收步骤 ${unitId} 已验收 ${next.join("、")}`);
|
||||
}
|
||||
slots[SLOT_CREATION_ACCEPTED_UNITS] = next;
|
||||
slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined;
|
||||
this.blackboard.write({
|
||||
@@ -1518,6 +1919,7 @@ export class PhaseRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("step", "总管开始");
|
||||
const availableWorkers = await this.resolveAvailableWorkers();
|
||||
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
@@ -1554,13 +1956,16 @@ export class PhaseRuntime {
|
||||
},
|
||||
);
|
||||
|
||||
debugLog(
|
||||
"step",
|
||||
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
|
||||
);
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = applyEvent(this.session, {
|
||||
const result = this.commitEvent({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
this.session = result.session;
|
||||
|
||||
if (result.error) {
|
||||
this.onMessage(result.error);
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
displayWorkerLabel,
|
||||
formatAgentDisplayTitle,
|
||||
formatWorkerDisplayTitle,
|
||||
reviewComposerCopy,
|
||||
} from "./display-labels.js";
|
||||
import {
|
||||
isModuleOpeningQuestions,
|
||||
normalizeQuestions,
|
||||
} from "../skills/question-protocol.js";
|
||||
|
||||
export type AgentMessageKind =
|
||||
| "user_input"
|
||||
@@ -167,6 +172,20 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
|
||||
};
|
||||
}
|
||||
|
||||
// 能力默认问题:正文在主栏说话面,调度消息只留短提示
|
||||
const moduleOpeningMsg = trimmed.match(
|
||||
/^\[Worker\]\s*(\S+)\s*·\s*默认问题/,
|
||||
);
|
||||
if (moduleOpeningMsg) {
|
||||
return {
|
||||
kind: "orchestrator_prompt",
|
||||
actor: moduleOpeningMsg[1],
|
||||
title: formatWorkerDisplayTitle(moduleOpeningMsg[1], "questions"),
|
||||
body: "默认问题(请在主栏按引导作答)",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const workerAskTagged = trimmed.match(
|
||||
/^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/,
|
||||
);
|
||||
@@ -240,7 +259,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
|
||||
title: "可选追问",
|
||||
body: formatWorkerQuestionBody(
|
||||
trimmed.replace(
|
||||
/^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物)[::]\s*/,
|
||||
/^\[Worker\]\s*可选追问(可跳过(?:,直接[^)]+)?)[::]\s*/,
|
||||
"",
|
||||
),
|
||||
),
|
||||
@@ -387,19 +406,52 @@ export function buildFocus(
|
||||
|
||||
if (reason?.kind === "approve_step") {
|
||||
const worker = session.pendingDecision?.workerId ?? "skill";
|
||||
const proposed =
|
||||
typeof session.slots["创作.待确认步骤"] === "string"
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(String(session.slots["创作.待确认步骤"])) as {
|
||||
name?: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
return {
|
||||
actorType: "orchestrator",
|
||||
actorId: "orchestrator",
|
||||
actorLabel: "总管",
|
||||
action: `建议调用 ${displayWorkerLabel(worker)}`,
|
||||
actorLabel: "编排",
|
||||
action: proposed?.name
|
||||
? `确认开始 · ${proposed.name}`
|
||||
: `建议调用 ${displayWorkerLabel(worker)}`,
|
||||
detail: session.pendingDecision?.reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "next_intent") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "下一步想写什么",
|
||||
detail: "可留空;发送后会展示下一节点供确认开干",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "worker_questions") {
|
||||
const qs = normalizeQuestions(reason.questions);
|
||||
if (isModuleOpeningQuestions(qs)) {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorId: reason.workerId,
|
||||
actorLabel: "你",
|
||||
action: "按引导先说几句",
|
||||
detail: (qs[0]?.prompt ?? "").slice(0, 200) || "想到什么写什么",
|
||||
};
|
||||
}
|
||||
const q =
|
||||
reason.questions
|
||||
?.map((item) => item.prompt)
|
||||
qs
|
||||
.map((item) => item.prompt)
|
||||
.filter((s) => s?.trim())
|
||||
.join(";") ?? "";
|
||||
return {
|
||||
@@ -413,13 +465,16 @@ export function buildFocus(
|
||||
|
||||
if (reason?.kind === "review_artifact") {
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
});
|
||||
const optionalQs = reason.questions?.length
|
||||
? `;另有 ${reason.questions.length} 道可选追问`
|
||||
: "";
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "验收产物",
|
||||
action: copy.taskTitle,
|
||||
detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,3 +122,84 @@ export function formatAgentDisplayTitle(detail?: string): string {
|
||||
if (detail?.trim()) return `编排器 · ${detail.trim()}`;
|
||||
return "编排器";
|
||||
}
|
||||
|
||||
/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
|
||||
export type ReviewComposerCopy = {
|
||||
/** 底栏短芯片 */
|
||||
chip: string;
|
||||
/** 工作面左上角 */
|
||||
kicker: string;
|
||||
taskLabel: string;
|
||||
taskTitle: string;
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
acceptLabel: string;
|
||||
placeholder: string;
|
||||
emptyEnterHint: string;
|
||||
/** plan=编排确认(主色);accept=产物验收(绿) */
|
||||
tone: "plan" | "accept";
|
||||
kind: "flow" | "opening" | "accept";
|
||||
};
|
||||
|
||||
/**
|
||||
* `review_artifact` 的用户文案。流程编排核对的是工作流计划,不是「验收通过」。
|
||||
*/
|
||||
export function reviewComposerCopy(
|
||||
workerId: string | undefined | null,
|
||||
opts?: { hasQuestions?: boolean },
|
||||
): ReviewComposerCopy {
|
||||
const hasQuestions = Boolean(opts?.hasQuestions);
|
||||
const id = (workerId ?? "").trim();
|
||||
|
||||
if (id === "design-flow") {
|
||||
return {
|
||||
chip: "编排",
|
||||
kicker: "核对编排",
|
||||
taskLabel: "编排",
|
||||
taskTitle: "核对工作流计划",
|
||||
hint: hasQuestions
|
||||
? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。"
|
||||
: "同意就按这个排;要改就写意见,在现有计划上改。",
|
||||
submitLabel: "按意见改编排",
|
||||
acceptLabel: "确认编排",
|
||||
placeholder: "改编排意见…",
|
||||
emptyEnterHint: "空 Enter=确认编排",
|
||||
tone: "plan",
|
||||
kind: "flow",
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "opening-generator") {
|
||||
return {
|
||||
chip: "开场",
|
||||
kicker: "待选定",
|
||||
taskLabel: "开场",
|
||||
taskTitle: "选定开场白",
|
||||
hint: hasQuestions
|
||||
? "同意就选定此开场;要改就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改就写意见。",
|
||||
submitLabel: "按意见修改",
|
||||
acceptLabel: "选定此开场",
|
||||
placeholder: "修改意见…",
|
||||
emptyEnterHint: "空 Enter=选定此开场",
|
||||
tone: "plan",
|
||||
kind: "opening",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chip: "验收",
|
||||
kicker: "待验收",
|
||||
taskLabel: "验收",
|
||||
taskTitle: "验收产物",
|
||||
hint: hasQuestions
|
||||
? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。"
|
||||
: "同意就接受;要改就写意见,在现有产物上改。",
|
||||
submitLabel: "按意见修改",
|
||||
acceptLabel: "接受",
|
||||
placeholder: "修改意见…",
|
||||
emptyEnterHint: "空 Enter=接受",
|
||||
tone: "accept",
|
||||
kind: "accept",
|
||||
};
|
||||
}
|
||||
|
||||
+428
-48
@@ -63,7 +63,12 @@ import type { BlackboardItem } from "../types/blackboard.js";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
|
||||
import { parseAcceptedUnits, isDesignDiskWorker } from "../skills/creation-units.js";
|
||||
import {
|
||||
CREATION_ACCEPTED_UNITS_TAG,
|
||||
isDesignDiskWorker,
|
||||
parseAcceptedUnits,
|
||||
SLOT_CREATION_ACCEPTED_UNITS,
|
||||
} from "../skills/creation-units.js";
|
||||
import {
|
||||
formatWorkerSetForUser,
|
||||
type WorkerSetUserView,
|
||||
@@ -78,13 +83,21 @@ import {
|
||||
type ContextOrderEdit,
|
||||
} from "../skills/context-order.js";
|
||||
import {
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_SELECTED_RECIPE_TAG,
|
||||
creationFlowFromRecipeSeed,
|
||||
findRecipeCatalogEntry,
|
||||
formatCreationFlowForUser,
|
||||
isProgressPointerTag,
|
||||
loadRecipeCatalog,
|
||||
loadRecipeDetail,
|
||||
parseCreationFlow,
|
||||
parseSelectedRecipeRef,
|
||||
resolveDesignStepBinding,
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
type CreationFlowUserView,
|
||||
type RecipeCatalogEntry,
|
||||
} from "../skills/creation-flow.js";
|
||||
@@ -94,6 +107,7 @@ import { buildBoardPanel } from "../runtime/compress-after-worker.js";
|
||||
import {
|
||||
displayWorkerLabel,
|
||||
formatWorkerDisplayTitle,
|
||||
reviewComposerCopy,
|
||||
} from "./display-labels.js";
|
||||
import {
|
||||
buildCreationDialogueTranscript,
|
||||
@@ -102,6 +116,7 @@ import {
|
||||
import {
|
||||
formatQuestionAnswersForAi,
|
||||
formatQuestionAnswersForDisplay,
|
||||
isModuleOpeningQuestions,
|
||||
normalizeQuestions,
|
||||
} from "../skills/question-protocol.js";
|
||||
import type { QuestionAnswer } from "../types/questions.js";
|
||||
@@ -180,10 +195,16 @@ export type SessionView = {
|
||||
workerSetView?: WorkerSetUserView;
|
||||
/** 创作流程用户视图(步骤顺序 + 依赖) */
|
||||
creationFlowView?: CreationFlowUserView;
|
||||
/** 确认开干:下一步提案(含可编辑编排参数) */
|
||||
proposedNextStep?: ProposedNextStepView;
|
||||
/** 能力包内可选初始配方(用户手动选) */
|
||||
recipes?: Array<{ id: string; name: string; declaration: string }>;
|
||||
/** 用户已选初始配方 */
|
||||
selectedRecipe?: { id: string; name: string; declaration: string } | null;
|
||||
/**
|
||||
* 开局引导:配方起点或当前能力默认问题的 opening(说话面主柱;对齐美学纲领)。
|
||||
*/
|
||||
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||
/** 黑板与定稿上下文(强可读) */
|
||||
boardPanel?: BoardPanelView;
|
||||
/** 游玩呈现壳微调(来自设计.正文组成) */
|
||||
@@ -238,6 +259,21 @@ export type BoardPanelView = {
|
||||
brief?: string;
|
||||
};
|
||||
|
||||
export type ProposedNextStepView = {
|
||||
stepId: string;
|
||||
name: string;
|
||||
declaration?: string;
|
||||
params: Record<string, unknown>;
|
||||
paramsMissing: string[];
|
||||
paramSpecs: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
hint?: string;
|
||||
}>;
|
||||
intent?: string;
|
||||
};
|
||||
|
||||
export type ReviewArtifactView = {
|
||||
id: string;
|
||||
workerId: string;
|
||||
@@ -263,14 +299,18 @@ type ManagedSession = {
|
||||
branchState: MessageBranchState;
|
||||
/** 当前能力包可选初始配方(用户手动选) */
|
||||
recipeOptions?: RecipeCatalogEntry[];
|
||||
/** 开局模块 opening,缓存给意图页 */
|
||||
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||
};
|
||||
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<string, ManagedSession>();
|
||||
/** bookId → 当前内存中的 sessionId */
|
||||
private readonly activeBookSessions = new Map<string, string>();
|
||||
/** sessionId → 流式 thinking 缓冲 */
|
||||
/** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */
|
||||
private readonly agentThinkingLive = new Map<string, string>();
|
||||
/** 本轮思维链已收束;下一次 delta 起新一轮 */
|
||||
private readonly agentThinkingClosed = new Set<string>();
|
||||
/** sessionId → worker 流式缓冲 */
|
||||
private readonly workerLive = new Map<
|
||||
string,
|
||||
@@ -679,7 +719,11 @@ export class SessionManager {
|
||||
return count;
|
||||
}
|
||||
|
||||
async sendMessage(id: string, text: string): Promise<SessionView> {
|
||||
async sendMessage(
|
||||
id: string,
|
||||
text: string,
|
||||
opts?: { answers?: QuestionAnswer[] },
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
this.clearAgentThinking(id);
|
||||
recordPreMessageCheckpoint(
|
||||
@@ -687,21 +731,62 @@ export class SessionManager {
|
||||
s.messages.length,
|
||||
this.captureCheckpoint(s),
|
||||
);
|
||||
s.messages.push(this.msg("user", text));
|
||||
this.syncCreationDialogue(s);
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
const reason = s.runtime.getSession().waitingReason;
|
||||
const trimmed = text.trim();
|
||||
const sidecarAnswers =
|
||||
reason?.kind === "review_artifact" &&
|
||||
reason.questions?.length &&
|
||||
opts?.answers?.length
|
||||
? opts.answers.filter((a) => a.text?.trim() && a.text !== "(未答)")
|
||||
: [];
|
||||
|
||||
// next_intent 允许留空:不塞空白气泡
|
||||
if (trimmed || reason?.kind !== "next_intent" || sidecarAnswers.length) {
|
||||
let recordText = trimmed || text;
|
||||
let displayBody: string | undefined;
|
||||
if (sidecarAnswers.length && reason?.kind === "review_artifact") {
|
||||
const qs = normalizeQuestions(reason.questions ?? []);
|
||||
recordText = formatQuestionAnswersForAi(
|
||||
qs,
|
||||
sidecarAnswers,
|
||||
trimmed || undefined,
|
||||
);
|
||||
displayBody = formatQuestionAnswersForDisplay(
|
||||
qs,
|
||||
sidecarAnswers,
|
||||
trimmed || undefined,
|
||||
);
|
||||
}
|
||||
const userMsg = this.msg("user", recordText);
|
||||
if (displayBody) userMsg.body = displayBody;
|
||||
s.messages.push(userMsg);
|
||||
this.syncCreationDialogue(s);
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
try {
|
||||
if (reason?.kind === "approve_step") {
|
||||
await s.runtime.rejectStep(text);
|
||||
await s.runtime.rejectStep(trimmed || text);
|
||||
} else if (reason?.kind === "review_artifact") {
|
||||
await s.runtime.rejectArtifact(text);
|
||||
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
|
||||
if (sidecarAnswers.length && reason.questions?.length) {
|
||||
const qaText = formatQuestionAnswersForAi(
|
||||
normalizeQuestions(reason.questions),
|
||||
sidecarAnswers,
|
||||
trimmed || undefined,
|
||||
);
|
||||
await s.runtime.resolveSidecarQuestions(qaText);
|
||||
}
|
||||
if (trimmed || sidecarAnswers.length) {
|
||||
await s.runtime.rejectArtifact(
|
||||
trimmed || "按追问作答更新产物",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await s.runtime.submitInput(text);
|
||||
await s.runtime.submitInput(trimmed || text);
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] sendMessage failed:", detail);
|
||||
console.error("[会话] 发送消息失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
} finally {
|
||||
@@ -755,12 +840,14 @@ export class SessionManager {
|
||||
try {
|
||||
if (isSidecar) {
|
||||
await s.runtime.resolveSidecarQuestions(aiText);
|
||||
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
|
||||
await s.runtime.rejectArtifact("按追问作答更新产物");
|
||||
} else {
|
||||
await s.runtime.submitInput(aiText);
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] answerQuestions failed:", detail);
|
||||
console.error("[会话] 回答追问失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
} finally {
|
||||
@@ -960,13 +1047,16 @@ export class SessionManager {
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async approve(id: string): Promise<SessionView> {
|
||||
async approve(
|
||||
id: string,
|
||||
stepParams?: Record<string, unknown>,
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
await s.runtime.approve();
|
||||
await s.runtime.approve(stepParams);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] approve failed:", detail);
|
||||
console.error("[会话] 确认开干失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
return this.toView(id);
|
||||
@@ -978,7 +1068,7 @@ export class SessionManager {
|
||||
await s.runtime.confirmIntake();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] confirmIntake failed:", detail);
|
||||
console.error("[会话] 确认需求失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
@@ -1028,7 +1118,7 @@ export class SessionManager {
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] reject failed:", detail);
|
||||
console.error("[会话] 打回失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
@@ -1154,7 +1244,7 @@ export class SessionManager {
|
||||
payload: { text },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[session] maybeKickAgentAfterDemand failed:", err);
|
||||
console.error("[会话] 需求后唤醒总管失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1214,7 +1304,7 @@ export class SessionManager {
|
||||
const transcript = buildCreationDialogueTranscript(s.messages);
|
||||
s.runtime.writeCreationDialogue(transcript);
|
||||
} catch (err) {
|
||||
console.warn("[session] syncCreationDialogue:", err);
|
||||
console.warn("[会话] 同步创作对话失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,6 +1364,7 @@ export class SessionManager {
|
||||
|
||||
private clearAgentThinking(sessionId: string): void {
|
||||
this.agentThinkingLive.delete(sessionId);
|
||||
this.agentThinkingClosed.delete(sessionId);
|
||||
this.workerLive.delete(sessionId);
|
||||
}
|
||||
|
||||
@@ -1287,15 +1378,20 @@ export class SessionManager {
|
||||
| "onWorkerStreamStart"
|
||||
| "onWorkerThinkingDelta"
|
||||
| "onWorkerOutputDelta"
|
||||
| "onWorkerStreamDone"
|
||||
> {
|
||||
return {
|
||||
onAgentThinkingDelta: (delta) => {
|
||||
const prev = this.agentThinkingLive.get(sessionId) ?? "";
|
||||
const prev = this.agentThinkingClosed.has(sessionId)
|
||||
? ""
|
||||
: (this.agentThinkingLive.get(sessionId) ?? "");
|
||||
this.agentThinkingClosed.delete(sessionId);
|
||||
this.agentThinkingLive.set(sessionId, prev + delta);
|
||||
},
|
||||
onAgentThinkingDone: () => {
|
||||
this.agentThinkingLive.delete(sessionId);
|
||||
onAgentThinkingDone: (text) => {
|
||||
// 正文仍等完整后再渲染;思考收束后继续挂在 liveStream,避免中间只剩「…」
|
||||
this.agentThinkingClosed.add(sessionId);
|
||||
const done = text.trim();
|
||||
if (done) this.agentThinkingLive.set(sessionId, done);
|
||||
},
|
||||
onWorkerStreamStart: (workerId) => {
|
||||
this.workerLive.set(sessionId, { workerId, thinking: "", output: "" });
|
||||
@@ -1322,9 +1418,6 @@ export class SessionManager {
|
||||
prev.output += delta;
|
||||
this.workerLive.set(sessionId, prev);
|
||||
},
|
||||
onWorkerStreamDone: () => {
|
||||
this.workerLive.delete(sessionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1603,47 +1696,94 @@ export class SessionManager {
|
||||
}
|
||||
} else if (reason?.kind === "worker_questions") {
|
||||
const qs = normalizeQuestions(reason.questions);
|
||||
if (qs.length) {
|
||||
if (isModuleOpeningQuestions(qs)) {
|
||||
// 能力默认问题:对齐美学纲领 → 自由书写,不是可跳过追问卡
|
||||
hints.push("按主栏引导先说几句(想到什么写什么,不必整齐)");
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "想到什么写什么…",
|
||||
});
|
||||
} else if (qs.length) {
|
||||
hints.push(
|
||||
`${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`,
|
||||
);
|
||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "自由补充",
|
||||
placeholder: "也可在此自由补充…",
|
||||
});
|
||||
} else {
|
||||
hints.push(
|
||||
`${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`,
|
||||
);
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "补充说明…",
|
||||
});
|
||||
}
|
||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "自由补充",
|
||||
placeholder: "也可在此自由补充…",
|
||||
});
|
||||
} else if (reason?.kind === "approve_step") {
|
||||
actions.push({ type: "approve", label: "确认执行" });
|
||||
const proposed = readProposedNextStep(
|
||||
s.runtime.getSession().slots,
|
||||
s.runtime.getBlackboard(),
|
||||
);
|
||||
if (proposed) {
|
||||
hints.push(
|
||||
`确认开始「${proposed.name}」${
|
||||
proposed.paramsMissing.length
|
||||
? `(请先补齐:${proposed.paramsMissing.join("、")})`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
actions.push({
|
||||
type: "approve",
|
||||
label: proposed.paramsMissing.length ? "补参后开始" : "同意并开始",
|
||||
});
|
||||
} else {
|
||||
actions.push({ type: "approve", label: "确认执行" });
|
||||
}
|
||||
actions.push({ type: "reject", label: "暂不执行" });
|
||||
} else if (reason?.kind === "review_artifact") {
|
||||
actions.push({ type: "accept", label: "接受目前产物" });
|
||||
actions.push({ type: "reject", label: "不接受,重新来" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送修改意见",
|
||||
placeholder: "输入修改意见后发送…",
|
||||
label: "说明意见",
|
||||
placeholder: "不同意可说明要改成什么…",
|
||||
});
|
||||
} else if (reason?.kind === "next_intent") {
|
||||
hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。");
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "继续",
|
||||
placeholder: "下一步想写什么?(可留空)…",
|
||||
});
|
||||
} else if (reason?.kind === "review_artifact") {
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
});
|
||||
actions.push({ type: "accept", label: copy.acceptLabel });
|
||||
actions.push({ type: "reject", label: "不接受" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: copy.submitLabel,
|
||||
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
|
||||
});
|
||||
if (reason.questions?.length) {
|
||||
hints.push("下方追问可选;接受产物即收起(表示无需再完善)");
|
||||
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
|
||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||
}
|
||||
} else if (reason?.kind === "revision") {
|
||||
hints.push(
|
||||
reason.instruction?.trim() ||
|
||||
"需要按反馈修改后继续;请在下方说明或直接发送继续",
|
||||
"请说明要改哪里;发送后在现有产物上修改",
|
||||
);
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "说明修改意见,或直接发送让总管继续…",
|
||||
label: "按意见修改",
|
||||
placeholder: "说明要改哪里…",
|
||||
});
|
||||
} else if (session.phase === "error") {
|
||||
hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。");
|
||||
@@ -1737,7 +1877,17 @@ export class SessionManager {
|
||||
s.runtime.getBlackboard(),
|
||||
s.runtime.getSession(),
|
||||
),
|
||||
creationFlowView: buildCreationFlowUserView(s.runtime.getBlackboard()),
|
||||
creationFlowView: buildCreationFlowUserView(
|
||||
s.runtime.getBlackboard(),
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
),
|
||||
proposedNextStep:
|
||||
reason?.kind === "approve_step"
|
||||
? readProposedNextStep(
|
||||
session.slots,
|
||||
s.runtime.getBlackboard(),
|
||||
)
|
||||
: undefined,
|
||||
recipes: s.recipeOptions?.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -1747,6 +1897,7 @@ export class SessionManager {
|
||||
s.runtime.getBlackboard(),
|
||||
s.recipeOptions,
|
||||
),
|
||||
openingGuide: this.resolveOpeningGuideForView(s, reason),
|
||||
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
|
||||
presentationTweaks: shellAdapt
|
||||
? {
|
||||
@@ -1839,12 +1990,14 @@ export class SessionManager {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (!skillName) {
|
||||
s.recipeOptions = [];
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const skill = await loadSkill(skillName);
|
||||
if (!skill.skillPackRoot) {
|
||||
s.recipeOptions = [];
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
|
||||
@@ -1852,6 +2005,125 @@ export class SessionManager {
|
||||
} catch {
|
||||
s.recipeOptions = [];
|
||||
}
|
||||
await this.ensureRecipeSeededFlow(s);
|
||||
await this.refreshOpeningGuide(s);
|
||||
}
|
||||
|
||||
/** 已选配方但尚无流程 → 写入配方近期起点(兼容旧会话) */
|
||||
private async ensureRecipeSeededFlow(s: ManagedSession): Promise<void> {
|
||||
const board = s.runtime.getBlackboard();
|
||||
if (board.getContentByTag(CREATION_FLOW_TAG)?.trim()) return;
|
||||
const ref = parseSelectedRecipeRef(
|
||||
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
|
||||
);
|
||||
if (!ref) return;
|
||||
const entry = findRecipeCatalogEntry(
|
||||
s.recipeOptions?.length ? { recipes: s.recipeOptions } : null,
|
||||
ref,
|
||||
);
|
||||
if (!entry) return;
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (!skillName) return;
|
||||
try {
|
||||
const skill = await loadSkill(skillName);
|
||||
const packRoot = skill.skillPackRoot?.trim();
|
||||
if (!packRoot) return;
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (!seeded) return;
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[会话] 配方预置失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一待做开局步(无 depends_on)的 opening → 意图页引导。
|
||||
* 有起点模块就展示;无则 null(再走 design-flow 生成)。
|
||||
* 中段步骤的默认问题改由 resolveOpeningGuideForView 在 waiting 时覆盖。
|
||||
*/
|
||||
private async refreshOpeningGuide(s: ManagedSession): Promise<void> {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (!skillName) {
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const skill = await loadSkill(skillName);
|
||||
const packRoot = skill.skillPackRoot?.trim();
|
||||
if (!packRoot) {
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
const board = s.runtime.getBlackboard();
|
||||
const session = s.runtime.getSession();
|
||||
const accepted = parseAcceptedUnits(
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
board.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
const binding = await resolveDesignStepBinding({
|
||||
skillPackRoot: packRoot,
|
||||
flowRaw: board.getContentByTag(CREATION_FLOW_TAG),
|
||||
acceptedStepNames: accepted,
|
||||
});
|
||||
if (
|
||||
!binding?.opening?.trim() ||
|
||||
(binding.step.depends_on?.length ?? 0) > 0
|
||||
) {
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
s.openingGuide = {
|
||||
stepId: binding.step.id,
|
||||
stepName: binding.module.name || binding.step.name,
|
||||
text: binding.opening.trim(),
|
||||
};
|
||||
} catch {
|
||||
s.openingGuide = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图用开场引导:等待能力默认问题时用题干(对齐美学纲领说话面);
|
||||
* 中段步骤(有 depends_on)也会覆盖,避免只剩空答题壳。
|
||||
*/
|
||||
private resolveOpeningGuideForView(
|
||||
s: ManagedSession,
|
||||
reason: WaitingReason | undefined,
|
||||
): { stepId: string; stepName: string; text: string } | null {
|
||||
if (reason?.kind === "worker_questions") {
|
||||
const qs = normalizeQuestions(reason.questions);
|
||||
if (isModuleOpeningQuestions(qs)) {
|
||||
const text = qs[0]?.prompt?.trim() ?? "";
|
||||
if (text) {
|
||||
const board = s.runtime.getBlackboard();
|
||||
const stepKey =
|
||||
board.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() || "";
|
||||
const flow = parseCreationFlow(board.getContentByTag(CREATION_FLOW_TAG));
|
||||
const step = flow?.steps?.find(
|
||||
(st) =>
|
||||
stepUnitId(st) === stepKey ||
|
||||
st.id === stepKey ||
|
||||
st.name === stepKey,
|
||||
);
|
||||
const stepName =
|
||||
step?.name?.trim() ||
|
||||
s.openingGuide?.stepName?.trim() ||
|
||||
stepKey ||
|
||||
"按引导先说几句";
|
||||
return {
|
||||
stepId: qs[0]!.id,
|
||||
stepName,
|
||||
text,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.openingGuide ?? null;
|
||||
}
|
||||
|
||||
private async applySelectedRecipe(
|
||||
@@ -1866,7 +2138,8 @@ export class SessionManager {
|
||||
if (!entry) {
|
||||
throw new Error(`未知初始配方:${recipeId}`);
|
||||
}
|
||||
s.runtime.getBlackboard().write({
|
||||
const board = s.runtime.getBlackboard();
|
||||
board.write({
|
||||
tag: CREATION_SELECTED_RECIPE_TAG,
|
||||
content: JSON.stringify({
|
||||
id: entry.id,
|
||||
@@ -1874,6 +2147,33 @@ export class SessionManager {
|
||||
}),
|
||||
source: "user",
|
||||
});
|
||||
|
||||
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow)
|
||||
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!existingFlow) {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (skillName) {
|
||||
try {
|
||||
const skill = await loadSkill(skillName);
|
||||
const packRoot = skill.skillPackRoot?.trim();
|
||||
if (packRoot) {
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (seeded) {
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[会话] 从配方写入创作流程失败", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshOpeningGuide(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1893,6 +2193,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
|
||||
|
||||
function buildCreationFlowUserView(
|
||||
blackboard: Blackboard,
|
||||
acceptedRaw?: unknown,
|
||||
): CreationFlowUserView | undefined {
|
||||
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!raw) return undefined;
|
||||
@@ -1900,7 +2201,56 @@ function buildCreationFlowUserView(
|
||||
if (!flow) {
|
||||
return { steps: [], parseError: "创作流程无法解析为 JSON(需要 steps 数组)" };
|
||||
}
|
||||
return formatCreationFlowForUser(flow);
|
||||
const accepted = parseAcceptedUnits(
|
||||
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
return formatCreationFlowForUser(flow, undefined, accepted);
|
||||
}
|
||||
|
||||
function readProposedNextStep(
|
||||
slots: Record<string, unknown>,
|
||||
blackboard: Blackboard,
|
||||
): ProposedNextStepView | undefined {
|
||||
const raw =
|
||||
(typeof slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
? String(slots[CREATION_PROPOSED_STEP_TAG])
|
||||
: null) || blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
|
||||
if (!raw?.trim()) return undefined;
|
||||
try {
|
||||
const doc = JSON.parse(raw) as Partial<ProposedNextStepView>;
|
||||
if (typeof doc.stepId !== "string" || typeof doc.name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
stepId: doc.stepId,
|
||||
name: doc.name,
|
||||
declaration:
|
||||
typeof doc.declaration === "string" ? doc.declaration : undefined,
|
||||
params:
|
||||
doc.params && typeof doc.params === "object" && !Array.isArray(doc.params)
|
||||
? (doc.params as Record<string, unknown>)
|
||||
: {},
|
||||
paramsMissing: Array.isArray(doc.paramsMissing)
|
||||
? doc.paramsMissing.map(String)
|
||||
: [],
|
||||
paramSpecs: Array.isArray(doc.paramSpecs)
|
||||
? doc.paramSpecs
|
||||
.map((p) => ({
|
||||
key: String((p as { key?: string }).key ?? ""),
|
||||
label: String((p as { label?: string }).label ?? ""),
|
||||
required: Boolean((p as { required?: boolean }).required),
|
||||
hint:
|
||||
typeof (p as { hint?: string }).hint === "string"
|
||||
? (p as { hint: string }).hint
|
||||
: undefined,
|
||||
}))
|
||||
.filter((p) => p.key)
|
||||
: [],
|
||||
intent: typeof doc.intent === "string" ? doc.intent : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSelectedRecipeView(
|
||||
@@ -1972,19 +2322,46 @@ function buildReviewArtifactView(
|
||||
|
||||
for (const tag of art.outputTags) {
|
||||
if (workerSetTags.includes(tag)) continue;
|
||||
if (isProgressPointerTag(tag)) continue;
|
||||
const content = blackboard.getContentByTag(tag);
|
||||
if (content?.trim()) {
|
||||
tagBodies.push(`## ${tag}\n\n${content.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退消息里也可能夹带「创作.当前步骤」段落,验收正文里去掉
|
||||
const stripPointerSections = (text: string): string => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "";
|
||||
const re = /^##\s+(.+?)\s*$/gm;
|
||||
const hits: Array<{ title: string; index: number; headerEnd: number }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(trimmed))) {
|
||||
hits.push({
|
||||
title: m[1]!.trim(),
|
||||
index: m.index,
|
||||
headerEnd: m.index + m[0].length,
|
||||
});
|
||||
}
|
||||
if (!hits.length) return trimmed;
|
||||
return hits
|
||||
.filter((h) => !isProgressPointerTag(h.title) && !/当前步骤|当前单位/.test(h.title))
|
||||
.map((h, i) => {
|
||||
const end = i + 1 < hits.length ? hits[i + 1]!.index : trimmed.length;
|
||||
const chunk = trimmed.slice(h.headerEnd, end).trim();
|
||||
return chunk ? `## ${h.title}\n\n${chunk}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const outputMsg = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.kind === "worker_output" && m.actor === art.workerId);
|
||||
|
||||
const body =
|
||||
tagBodies.join("\n\n") ||
|
||||
(outputMsg?.body ?? outputMsg?.text ?? "").trim() ||
|
||||
stripPointerSections((outputMsg?.body ?? outputMsg?.text ?? "").trim()) ||
|
||||
art.summary?.trim() ||
|
||||
"(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)";
|
||||
|
||||
@@ -2028,7 +2405,10 @@ function buildReviewArtifactView(
|
||||
: undefined;
|
||||
|
||||
const creationFlowView = isFlowArt
|
||||
? buildCreationFlowUserView(blackboard)
|
||||
? buildCreationFlowUserView(
|
||||
blackboard,
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { sessionManager } from "./session-manager.js";
|
||||
import { handleSettingsApi } from "./settings-handlers.js";
|
||||
import { handleBooksApi } from "./book-handlers.js";
|
||||
import { handleStatsApi } from "./stats-handlers.js";
|
||||
import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
||||
ensureConsoleUtf8();
|
||||
loadDotEnv(PROJECT_ROOT);
|
||||
const WEB_ROOT = path.resolve(__dirname, "../../web");
|
||||
const PORT = Number(process.env.PORT ?? 23337);
|
||||
@@ -122,12 +124,45 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/messages") {
|
||||
const body = JSON.parse(await readBody(req)) as { text?: string };
|
||||
if (!body.text?.trim()) {
|
||||
json(res, 400, { error: "text 不能为空" });
|
||||
return;
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
text?: string;
|
||||
answers?: Array<{
|
||||
questionId?: string;
|
||||
optionId?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
const text = typeof body.text === "string" ? body.text : "";
|
||||
const trimmed = text.trim();
|
||||
const answers = (body.answers ?? [])
|
||||
.filter(
|
||||
(a) =>
|
||||
typeof a?.questionId === "string" &&
|
||||
a.questionId.trim() &&
|
||||
typeof a?.text === "string" &&
|
||||
a.text.trim(),
|
||||
)
|
||||
.map((a) => ({
|
||||
questionId: a.questionId!.trim(),
|
||||
optionId:
|
||||
typeof a.optionId === "string" && a.optionId.trim()
|
||||
? a.optionId.trim()
|
||||
: undefined,
|
||||
text: a.text!.trim(),
|
||||
}));
|
||||
// next_intent 明确可留空;其余态仍要求有字(除非顺带提交了追问)
|
||||
if (!trimmed && !answers.length) {
|
||||
const current = sessionManager.get(sessionId);
|
||||
if (current?.waitingReason?.kind !== "next_intent") {
|
||||
json(res, 400, { error: "text 不能为空" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const view = await sessionManager.sendMessage(sessionId, body.text.trim());
|
||||
const view = await sessionManager.sendMessage(
|
||||
sessionId,
|
||||
trimmed,
|
||||
answers.length ? { answers } : undefined,
|
||||
);
|
||||
json(res, 200, view);
|
||||
return;
|
||||
}
|
||||
@@ -406,11 +441,19 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/actions") {
|
||||
const body = JSON.parse(await readBody(req)) as { action?: string };
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
action?: string;
|
||||
stepParams?: Record<string, unknown>;
|
||||
};
|
||||
let view;
|
||||
switch (body.action) {
|
||||
case "approve":
|
||||
view = await sessionManager.approve(sessionId);
|
||||
view = await sessionManager.approve(
|
||||
sessionId,
|
||||
body.stepParams && typeof body.stepParams === "object"
|
||||
? body.stepParams
|
||||
: undefined,
|
||||
);
|
||||
break;
|
||||
case "confirm_intake":
|
||||
view = await sessionManager.confirmIntake(sessionId);
|
||||
@@ -452,7 +495,7 @@ const server = createServer(async (req, res) => {
|
||||
json(res, 404, { error: "Not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[api]", req.method, url.pathname, err);
|
||||
console.error("[接口]", req.method, url.pathname, err);
|
||||
json(res, 500, {
|
||||
error: err instanceof Error ? err.message : "服务器错误",
|
||||
});
|
||||
@@ -460,5 +503,6 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Writing Agent 对话页: http://localhost:${PORT}`);
|
||||
console.log(`对话页 http://localhost:${PORT}`);
|
||||
console.log(`运行日志(UTF-8) ${getRuntimeLogPath()}`);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,46 @@ export function isContextFragmentDoc(doc: unknown): boolean {
|
||||
return Boolean(parseContextFragment(doc));
|
||||
}
|
||||
|
||||
const NON_FRAGMENT_DESIGN_TAGS = new Set([
|
||||
"设计.创作流程",
|
||||
"设计.worker集",
|
||||
"设计.worker集.草稿",
|
||||
"设计.worker规格",
|
||||
"设计.上下文投影排序",
|
||||
]);
|
||||
|
||||
/** 该 output tag 应按 context-fragment.v1 验收(有可用「正文」) */
|
||||
export function expectsContextFragmentTag(tag: string): boolean {
|
||||
const t = tag.trim();
|
||||
if (!t.startsWith("设计.")) return false;
|
||||
return !NON_FRAGMENT_DESIGN_TAGS.has(t);
|
||||
}
|
||||
|
||||
export function hasUsableFragmentBody(body: unknown): boolean {
|
||||
if (body == null) return false;
|
||||
if (typeof body === "string") return body.trim().length > 0;
|
||||
if (Array.isArray(body)) return body.length > 0;
|
||||
if (typeof body === "object") return Object.keys(body as object).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 能进验收卡:解析成功且「正文」非空 */
|
||||
export function isUsableContextFragment(raw: unknown): boolean {
|
||||
const frag = parseContextFragment(raw);
|
||||
return Boolean(frag && hasUsableFragmentBody(frag.正文));
|
||||
}
|
||||
|
||||
export function looksLikeFragmentDoc(doc: unknown): boolean {
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
|
||||
const row = doc as Record<string, unknown>;
|
||||
if (row.schema === CONTEXT_FRAGMENT_SCHEMA) return true;
|
||||
if (typeof row.技能 === "string" && row.技能.trim()) return true;
|
||||
return (
|
||||
typeof row.brief === "string" &&
|
||||
("正文" in row || "body" in row)
|
||||
);
|
||||
}
|
||||
|
||||
/** 投影级别:summary 优先 brief;fields 尝试列出正文顶层键 */
|
||||
export function projectFragmentContent(
|
||||
raw: string,
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { BlackboardInputMerge } from "../types/blackboard.js";
|
||||
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
|
||||
import {
|
||||
CREATION_ACCEPTED_CONTENT_TAG,
|
||||
CREATION_ACCEPTED_UNITS_TAG,
|
||||
formatAcceptedContentForPrompt,
|
||||
formatAcceptedUnitsForPrompt,
|
||||
} from "./creation-units.js";
|
||||
import { projectFragmentContent } from "./context-fragment.js";
|
||||
import {
|
||||
@@ -107,6 +109,8 @@ function formatSegmentBody(
|
||||
if (!content) continue;
|
||||
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
|
||||
content = formatAcceptedContentForPrompt(content);
|
||||
} else if (tag === CREATION_ACCEPTED_UNITS_TAG) {
|
||||
content = formatAcceptedUnitsForPrompt(content);
|
||||
}
|
||||
content = applyPolicy(content, segment.policy);
|
||||
content = applyProjection(content, segment.projection);
|
||||
@@ -154,15 +158,20 @@ export function assembleWorkerContext(params: {
|
||||
const segments = params.segments ?? [];
|
||||
|
||||
if (segments.length === 0) {
|
||||
const revisionNote = (
|
||||
params.inputs["用户.修订说明"] ??
|
||||
""
|
||||
).trim();
|
||||
return JSON.stringify(
|
||||
{
|
||||
workerId: params.workerId,
|
||||
workerName: params.workerName,
|
||||
outputTags: params.outputTags,
|
||||
inputs: params.inputs,
|
||||
instruction:
|
||||
"根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
|
||||
"设计.worker集 / 草稿须为 JSON 对象文本。",
|
||||
instruction: revisionNote
|
||||
? "修订模式:在已有产物(inputs 中对应 outputTags)上按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)修改;保留未点名要改的部分,禁止无故整份重写。节点方法(task/principles/probe/output)仍须遵守。"
|
||||
: "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
|
||||
"设计.worker集 / 草稿须为 JSON 对象文本。",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -191,18 +200,61 @@ export function assembleWorkerContext(params: {
|
||||
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
[
|
||||
"## 本步任务",
|
||||
"",
|
||||
`- workerId: \`${params.workerId}\``,
|
||||
`- workerName: ${params.workerName}`,
|
||||
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
|
||||
"",
|
||||
// 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
|
||||
const revisionNote = readTagContent(
|
||||
"用户.修订说明",
|
||||
params.inputs,
|
||||
params.blackboard,
|
||||
inputMerge,
|
||||
).trim();
|
||||
const draftBlocks: string[] = [];
|
||||
if (revisionNote) {
|
||||
for (const tag of params.outputTags) {
|
||||
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
|
||||
if (alreadyInSegments) continue;
|
||||
const draft = readTagContent(
|
||||
tag,
|
||||
params.inputs,
|
||||
params.blackboard,
|
||||
inputMerge,
|
||||
).trim();
|
||||
if (!draft) continue;
|
||||
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
|
||||
}
|
||||
if (draftBlocks.length) {
|
||||
blocks.push(
|
||||
[
|
||||
"## 【待改底稿】",
|
||||
"",
|
||||
"下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。",
|
||||
"节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。",
|
||||
"",
|
||||
...draftBlocks,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const taskLines = [
|
||||
"## 本步任务",
|
||||
"",
|
||||
`- workerId: \`${params.workerId}\``,
|
||||
`- workerName: ${params.workerName}`,
|
||||
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
|
||||
"",
|
||||
];
|
||||
if (revisionNote) {
|
||||
taskLines.push(
|
||||
"**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。",
|
||||
"不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。",
|
||||
);
|
||||
} else {
|
||||
taskLines.push(
|
||||
"按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
|
||||
"设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。",
|
||||
].join("\n"),
|
||||
);
|
||||
);
|
||||
}
|
||||
taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
|
||||
blocks.push(taskLines.join("\n"));
|
||||
|
||||
return blocks.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
+333
-17
@@ -13,8 +13,36 @@ import { parse as parseYaml } from "yaml";
|
||||
|
||||
export const CREATION_FLOW_TAG = "设计.创作流程";
|
||||
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
|
||||
/** 确认开干前:待确认的下一步快照(JSON) */
|
||||
export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤";
|
||||
/** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */
|
||||
export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方";
|
||||
|
||||
/** 进度指针:禁止当作 worker 产物写回或列入验收正文 */
|
||||
export const PROGRESS_POINTER_TAGS = new Set<string>([
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_SELECTED_RECIPE_TAG,
|
||||
"创作.当前单位",
|
||||
"创作.已验收单位",
|
||||
"创作.已验收内容",
|
||||
"创作.能力开场白",
|
||||
"创作.能力开场状态",
|
||||
]);
|
||||
|
||||
/** 旧名:含「设计.创作流程」。写回/验收过滤请用 isProgressPointerTag */
|
||||
export const RUNTIME_PINNED_TAGS = new Set<string>([
|
||||
CREATION_FLOW_TAG,
|
||||
...PROGRESS_POINTER_TAGS,
|
||||
]);
|
||||
|
||||
export function isProgressPointerTag(tag: string): boolean {
|
||||
return PROGRESS_POINTER_TAGS.has(tag.trim());
|
||||
}
|
||||
|
||||
export function isRuntimePinnedTag(tag: string): boolean {
|
||||
return RUNTIME_PINNED_TAGS.has(tag.trim());
|
||||
}
|
||||
export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
|
||||
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
|
||||
export const DESIGN_STEP_WORKER_ID = "design-step";
|
||||
@@ -152,6 +180,9 @@ export type CreationFlowValidation = {
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/** 工作流计划节点相对执行进度(给人看的三态) */
|
||||
export type FlowStepRunState = "done" | "current" | "pending";
|
||||
|
||||
export type CreationFlowUserView = {
|
||||
brief?: string;
|
||||
status?: CreationFlowStatus;
|
||||
@@ -169,6 +200,8 @@ export type CreationFlowUserView = {
|
||||
params?: CreationFlowStepParams;
|
||||
/** 参数缺必填项时的提示(给人看) */
|
||||
paramsMissing?: string[];
|
||||
/** 已执行 / 将要执行 / 未执行 */
|
||||
runState: FlowStepRunState;
|
||||
}>;
|
||||
parseError?: string;
|
||||
};
|
||||
@@ -301,6 +334,42 @@ export function stepUnitId(step: CreationFlowStep): string {
|
||||
return step.id || step.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收时记下哪一步做完。只认流程里的真实 step.id:
|
||||
* LLM 可能把产物误写进「创作.当前步骤」,那种内容不能当进度。
|
||||
*/
|
||||
export function pickRecordedStepId(params: {
|
||||
flow: CreationFlow | null;
|
||||
alreadyAccepted?: readonly string[];
|
||||
pinnedUnitId?: string | null;
|
||||
writtenCurrentStep?: string | null;
|
||||
summaryHint?: string | null;
|
||||
}): string | null {
|
||||
const flow = params.flow;
|
||||
const tryRef = (ref: string | undefined | null): string | null => {
|
||||
const key = ref?.trim();
|
||||
if (!key || key === "flow") return null;
|
||||
if (!flow) return key;
|
||||
const step = findStepByRef(flow, key);
|
||||
return step ? stepUnitId(step) : null;
|
||||
};
|
||||
|
||||
const pinned = tryRef(params.pinnedUnitId);
|
||||
if (pinned) return pinned;
|
||||
|
||||
const written = tryRef(params.writtenCurrentStep);
|
||||
if (written) return written;
|
||||
|
||||
const hinted = tryRef(params.summaryHint);
|
||||
if (hinted) return hinted;
|
||||
|
||||
if (flow) {
|
||||
const pending = nextPendingStep(flow, params.alreadyAccepted ?? []);
|
||||
if (pending) return stepUnitId(pending);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null {
|
||||
if (!raw?.trim()) return null;
|
||||
const doc = extractJsonObject(raw);
|
||||
@@ -591,6 +660,93 @@ export function stringifyModuleOpeningState(state: ModuleOpeningState): string {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化「设计.创作流程」(程序 seed / 验收写回共用)。
|
||||
*/
|
||||
export function stringifyCreationFlow(flow: CreationFlow): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
...(flow.brief ? { brief: flow.brief } : {}),
|
||||
...(flow.status ? { status: flow.status } : {}),
|
||||
steps: flow.steps,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编排层重排流程时,已验收步骤按原样保留:
|
||||
* 缺了就按原位置补回,改了名字/参数就还原。编排只能动未验收步。
|
||||
*/
|
||||
export function mergeCreationFlowPreservingAccepted(params: {
|
||||
prevRaw: string | null | undefined;
|
||||
nextRaw: string;
|
||||
acceptedStepIds: readonly string[];
|
||||
}): { raw: string; restored: string[] } {
|
||||
const next = parseCreationFlow(params.nextRaw);
|
||||
const prev = parseCreationFlow(params.prevRaw);
|
||||
if (!next || !prev) return { raw: params.nextRaw, restored: [] };
|
||||
|
||||
const accepted = prev.steps.filter((s) => isStepAccepted(s, params.acceptedStepIds));
|
||||
if (accepted.length === 0) return { raw: params.nextRaw, restored: [] };
|
||||
|
||||
const restored: string[] = [];
|
||||
const steps = [...next.steps];
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const frozen = accepted[i]!;
|
||||
const at = steps.findIndex((s) => s.id === frozen.id);
|
||||
if (at < 0) {
|
||||
steps.splice(Math.min(i, steps.length), 0, frozen);
|
||||
restored.push(frozen.id);
|
||||
continue;
|
||||
}
|
||||
const current = steps[at]!;
|
||||
const changed =
|
||||
current.name !== frozen.name ||
|
||||
JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) ||
|
||||
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null);
|
||||
if (changed) {
|
||||
steps[at] = frozen;
|
||||
restored.push(frozen.id);
|
||||
}
|
||||
}
|
||||
if (restored.length === 0) return { raw: params.nextRaw, restored: [] };
|
||||
return {
|
||||
raw: stringifyCreationFlow({ ...next, version: 1, steps }),
|
||||
restored,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 配方近期起点 → 可写入黑板的开局 DAG。
|
||||
* steps 为空则返回 null(仍只当选型参考,不预置流程)。
|
||||
*/
|
||||
export function creationFlowFromRecipeSeed(
|
||||
detail: Pick<RecipeDetail, "seed">,
|
||||
): CreationFlow | null {
|
||||
const seed = detail.seed;
|
||||
if (!seed?.steps?.length) return null;
|
||||
return {
|
||||
version: 1,
|
||||
...(seed.brief ? { brief: seed.brief } : {}),
|
||||
status: seed.status ?? "open",
|
||||
steps: ensureCreationFlowStepIds(seed.steps),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 已坐在配方开局步(无 depends_on)且「用户.需求」有首句时:
|
||||
* 跳过程序再抛模块 opening——首句即该步对话,不是「先开场再进 DAG」。
|
||||
*/
|
||||
export function shouldSkipModuleOpening(opts: {
|
||||
demand: string;
|
||||
dependsOn?: string[] | null;
|
||||
phase?: "shown" | "answered" | null;
|
||||
}): boolean {
|
||||
if (opts.phase === "shown" || opts.phase === "answered") return false;
|
||||
if (!String(opts.demand ?? "").trim()) return false;
|
||||
const deps = opts.dependsOn ?? [];
|
||||
return deps.length === 0;
|
||||
}
|
||||
|
||||
/** 无 id 时的兜底(目录仍应显式写 id) */
|
||||
function slugFromName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
@@ -661,6 +817,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
|
||||
"【能力 · 可选工序】",
|
||||
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
|
||||
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
|
||||
"生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
|
||||
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
|
||||
lines.join("\n"),
|
||||
].join("\n");
|
||||
@@ -929,13 +1086,23 @@ function formatRecipeFieldBlock(value: string | string[]): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** design-flow 一次注入:已选配方 + 技能池 */
|
||||
/** design-flow 一次注入:流程进度 + 已选配方 + 技能池 */
|
||||
export function formatDesignFlowContentBlocks(params: {
|
||||
selectedRecipe?: RecipeDetail | null;
|
||||
modules?: ModuleCatalog | null;
|
||||
missingSelection?: boolean;
|
||||
flow?: CreationFlow | null;
|
||||
acceptedStepIds?: readonly string[];
|
||||
filledArtifactTags?: readonly string[];
|
||||
}): string[] {
|
||||
const blocks: string[] = [];
|
||||
const blocks: string[] = [
|
||||
formatFlowProgressForAgent({
|
||||
flow: params.flow,
|
||||
acceptedStepIds: params.acceptedStepIds,
|
||||
catalog: params.modules,
|
||||
filledArtifactTags: params.filledArtifactTags,
|
||||
}),
|
||||
];
|
||||
if (params.missingSelection) {
|
||||
blocks.push(
|
||||
[
|
||||
@@ -953,6 +1120,120 @@ export function formatDesignFlowContentBlocks(params: {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编排器必须看见的进度:哪些步已做完、草案里已有什么、哪些能力才允许再编入。
|
||||
* 裸 JSON id 列表不够;此块由程序钉死。
|
||||
*/
|
||||
export function formatFlowProgressForAgent(params: {
|
||||
flow?: CreationFlow | null;
|
||||
acceptedStepIds?: readonly string[];
|
||||
catalog?: ModuleCatalog | null;
|
||||
filledArtifactTags?: readonly string[];
|
||||
}): string {
|
||||
const accepted = (params.acceptedStepIds ?? []).filter(
|
||||
(id) => Boolean(id?.trim()) && id.trim() !== "flow",
|
||||
);
|
||||
const flow = params.flow ?? null;
|
||||
const catalog = params.catalog ?? null;
|
||||
const filled = new Set(params.filledArtifactTags ?? []);
|
||||
const repeatableNames = new Set(
|
||||
(catalog?.modules ?? [])
|
||||
.filter((m) => m.repeatable === true)
|
||||
.map((m) => m.name),
|
||||
);
|
||||
|
||||
const doneLines: string[] = [];
|
||||
const doneNames = new Set<string>();
|
||||
const doneIds = new Set<string>();
|
||||
|
||||
const markDone = (
|
||||
name: string,
|
||||
id: string | undefined,
|
||||
why: string,
|
||||
repeatable: boolean,
|
||||
) => {
|
||||
const flag = repeatable
|
||||
? "〔可反复:可再追加不同 id〕"
|
||||
: "〔不可反复:禁止再排一次执行〕";
|
||||
const idBit = id ? `(id: ${id})` : "";
|
||||
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
|
||||
doneNames.add(name);
|
||||
if (id) doneIds.add(id);
|
||||
};
|
||||
|
||||
if (flow) {
|
||||
for (const step of flow.steps) {
|
||||
const acceptedHere = isStepAccepted(step, accepted);
|
||||
const art = catalog ? artifactTagForStep(step.name, catalog) : null;
|
||||
const hasArtifact = Boolean(art && filled.has(art));
|
||||
const repeatable = repeatableNames.has(step.name);
|
||||
if (acceptedHere || (!repeatable && hasArtifact)) {
|
||||
markDone(
|
||||
step.name,
|
||||
step.id,
|
||||
acceptedHere ? "已验收" : "产物已在黑板",
|
||||
repeatable,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of accepted) {
|
||||
if (doneIds.has(id)) continue;
|
||||
if (flow?.steps.some((s) => isStepAccepted(s, [id]))) continue;
|
||||
markDone(id, id, "已验收", repeatableNames.has(id));
|
||||
}
|
||||
|
||||
if (catalog) {
|
||||
for (const mod of catalog.modules) {
|
||||
if (mod.repeatable === true) continue;
|
||||
if (!mod.artifact || !filled.has(mod.artifact)) continue;
|
||||
if (doneNames.has(mod.name)) continue;
|
||||
markDone(mod.name, undefined, "产物已在黑板", false);
|
||||
}
|
||||
}
|
||||
|
||||
const draftLines: string[] = [];
|
||||
if (flow) {
|
||||
for (const step of flow.steps) {
|
||||
if (doneIds.has(step.id) || doneNames.has(step.name)) continue;
|
||||
draftLines.push(
|
||||
`- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const repeatableList = (catalog?.modules ?? [])
|
||||
.filter((m) => m.repeatable === true)
|
||||
.map((m) => m.name);
|
||||
|
||||
const lines = [
|
||||
"【流程进度】(程序钉死,必须遵守)",
|
||||
"你的任务是追加缺口,不是从头规划。",
|
||||
"已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。",
|
||||
"草案里已有的步骤:原样保留,不要重排一遍开局。",
|
||||
"只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。",
|
||||
];
|
||||
if (doneLines.length) {
|
||||
lines.push("", "已完成:", ...doneLines);
|
||||
} else {
|
||||
lines.push(
|
||||
"",
|
||||
"已完成:尚无已验收步骤。若草案已有开局步,那是配方预置起点,不是你新规划的。",
|
||||
);
|
||||
}
|
||||
if (draftLines.length) {
|
||||
lines.push("", "草案已有、尚未验收(保留,勿重排):", ...draftLines);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
repeatableList.length
|
||||
? `可反复追加:${repeatableList.join("、")}`
|
||||
: "可反复追加:以能力目录〔可反复〕为准。",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** 解析并加载用户已选配方详情 */
|
||||
export async function resolveSelectedRecipeDetail(params: {
|
||||
skillPackRoot: string;
|
||||
@@ -973,7 +1254,6 @@ export function validateCreationFlow(
|
||||
catalog: ModuleCatalog | null,
|
||||
): CreationFlowValidation {
|
||||
const errors: string[] = [];
|
||||
const ids = flow.steps.map((s) => s.id);
|
||||
const seenIds = new Set<string>();
|
||||
const allowed = catalog
|
||||
? new Set(catalog.modules.map((m) => m.name))
|
||||
@@ -1020,25 +1300,53 @@ export function validateCreationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
if (mod?.params?.length) {
|
||||
const missing = missingRequiredStepParams(step, mod);
|
||||
if (missing.length > 0) {
|
||||
const labels = missing
|
||||
.map((key) => {
|
||||
const spec = mod.params!.find((p) => p.key === key);
|
||||
return spec ? `${key}(${spec.label})` : key;
|
||||
})
|
||||
.join("、");
|
||||
errors.push(
|
||||
`「${step.id}」缺少必填编排参数:${labels}(须在 design-flow 钉齐后再执行)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 必填 params 改在「确认开干」时钉齐,不再阻挡编排产出空壳步骤
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** 进 design-step 前:本步必填编排参数是否已齐 */
|
||||
export function validateStepReadyToRun(
|
||||
step: CreationFlowStep,
|
||||
catalog: ModuleCatalog | null,
|
||||
): CreationFlowValidation {
|
||||
const mod = catalog?.modules.find((m) => m.name === step.name);
|
||||
if (!mod?.params?.length) return { ok: true, errors: [] };
|
||||
const missing = missingRequiredStepParams(step, mod);
|
||||
if (missing.length === 0) return { ok: true, errors: [] };
|
||||
const labels = missing
|
||||
.map((key) => {
|
||||
const spec = mod.params!.find((p) => p.key === key);
|
||||
return spec ? `${key}(${spec.label})` : key;
|
||||
})
|
||||
.join("、");
|
||||
return {
|
||||
ok: false,
|
||||
errors: [`「${step.id}」缺少必填编排参数:${labels}(确认开干前请补齐)`],
|
||||
};
|
||||
}
|
||||
|
||||
/** 写回某步 params(确认下一节点时由用户钉死) */
|
||||
export function patchCreationFlowStepParams(
|
||||
flow: CreationFlow,
|
||||
stepId: string,
|
||||
params: CreationFlowStepParams,
|
||||
): CreationFlow {
|
||||
const id = stepId.trim();
|
||||
return {
|
||||
...flow,
|
||||
steps: flow.steps.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
params: Object.keys(params).length ? { ...params } : undefined,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function artifactTagForStep(
|
||||
name: string,
|
||||
catalog: ModuleCatalog | null,
|
||||
@@ -1049,11 +1357,13 @@ export function artifactTagForStep(
|
||||
export function formatCreationFlowForUser(
|
||||
flow: CreationFlow,
|
||||
catalog?: ModuleCatalog | null,
|
||||
acceptedStepIds: readonly string[] = [],
|
||||
): CreationFlowUserView {
|
||||
const decl = new Map(
|
||||
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
|
||||
);
|
||||
const seenName = new Map<string, number>();
|
||||
const next = nextPendingStep(flow, acceptedStepIds);
|
||||
return {
|
||||
brief: flow.brief,
|
||||
status: flow.status,
|
||||
@@ -1062,6 +1372,11 @@ export function formatCreationFlowForUser(
|
||||
seenName.set(s.name, n);
|
||||
const mod = decl.get(s.name);
|
||||
const paramsMissing = missingRequiredStepParams(s, mod);
|
||||
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
|
||||
? "done"
|
||||
: next && next.id === s.id
|
||||
? "current"
|
||||
: "pending";
|
||||
return {
|
||||
order: i + 1,
|
||||
id: s.id,
|
||||
@@ -1070,6 +1385,7 @@ export function formatCreationFlowForUser(
|
||||
occurrence: n,
|
||||
declaration: mod?.declaration,
|
||||
repeatable: mod?.repeatable,
|
||||
runState,
|
||||
...(s.params ? { params: s.params } : {}),
|
||||
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
|
||||
};
|
||||
|
||||
@@ -491,6 +491,13 @@ export function parseAcceptedUnits(raw: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 给人 / 编排 LLM 看的已验收步骤清单;去掉「flow」(那是计划本身,不是某一步)。 */
|
||||
export function formatAcceptedUnitsForPrompt(raw: unknown): string {
|
||||
const ids = parseAcceptedUnits(raw).filter((id) => id && id !== "flow");
|
||||
if (ids.length === 0) return "(尚无已验收步骤)";
|
||||
return ids.map((id) => `- ${id}`).join("\n");
|
||||
}
|
||||
|
||||
export function isFinalWorkerSetArtifact(artifact: {
|
||||
workerId: string;
|
||||
outputTags: string[];
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: {
|
||||
currentStepName,
|
||||
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -473,6 +473,8 @@ export async function loadWorkerSkillWithContext(
|
||||
acceptedStepNames?: readonly string[];
|
||||
/** 用户手动选定的配方 id / 名 */
|
||||
selectedRecipeRef?: string | null;
|
||||
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
|
||||
filledArtifactTags?: readonly string[];
|
||||
},
|
||||
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
||||
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
||||
@@ -488,6 +490,7 @@ export async function loadWorkerSkillWithContext(
|
||||
loadModuleCatalog,
|
||||
resolveSelectedRecipeDetail,
|
||||
formatDesignFlowContentBlocks,
|
||||
parseCreationFlow,
|
||||
} = await import("./creation-flow.js");
|
||||
const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot);
|
||||
const selectedRecipe = await resolveSelectedRecipeDetail({
|
||||
@@ -499,6 +502,9 @@ export async function loadWorkerSkillWithContext(
|
||||
selectedRecipe,
|
||||
modules,
|
||||
missingSelection: !selectedRecipe,
|
||||
flow: parseCreationFlow(opts?.flowRaw),
|
||||
acceptedStepIds: opts?.acceptedStepNames,
|
||||
filledArtifactTags: opts?.filledArtifactTags,
|
||||
});
|
||||
if (blocks.length) {
|
||||
moduleCatalogBlock = blocks.join("\n\n");
|
||||
@@ -537,10 +543,7 @@ export async function loadWorkerSkillWithContext(
|
||||
...binding.depTags,
|
||||
];
|
||||
const inputTags = [...new Set(baseInputs)];
|
||||
const outputTags = [
|
||||
binding.module.artifact,
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
];
|
||||
const outputTags = [binding.module.artifact];
|
||||
const depSegments = binding.depTags.map((tag, i) => ({
|
||||
id: `dep-${i}`,
|
||||
tier: "static" as const,
|
||||
|
||||
@@ -10,6 +10,17 @@ import type {
|
||||
|
||||
export type { QuestionAnswer, QuestionItem, QuestionOption };
|
||||
|
||||
/** 能力默认问题(opening):与追问卡分流,对齐美学纲领开局引导 */
|
||||
export const MODULE_OPENING_QUESTION_ID = "module-opening";
|
||||
|
||||
/** 是否仅为能力默认问题(应走说话面 + openingGuide,不进答题卡) */
|
||||
export function isModuleOpeningQuestions(
|
||||
questions: readonly QuestionItem[] | undefined | null,
|
||||
): boolean {
|
||||
if (!questions?.length || questions.length !== 1) return false;
|
||||
return questions[0]?.id === MODULE_OPENING_QUESTION_ID;
|
||||
}
|
||||
|
||||
function letterId(i: number): string {
|
||||
return String.fromCharCode(65 + (i % 26));
|
||||
}
|
||||
@@ -88,6 +99,138 @@ export function normalizeQuestions(raw: unknown): QuestionItem[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const EXAMPLE_SUFFIX = /(?:\n|^)\s*示例[::][\s\S]*$/;
|
||||
|
||||
/** 去掉「示例:」后比题干,避免同一问因示例行对不上 */
|
||||
export function questionPromptStem(prompt: string): string {
|
||||
return prompt.replace(EXAMPLE_SUFFIX, "").replace(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
function optionSignature(q: QuestionItem): string {
|
||||
return (q.options ?? [])
|
||||
.map((o) => o.label.trim())
|
||||
.filter((label) => label && !/^其它/.test(label))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function isSameFollowUpQuestion(a: QuestionItem, b: QuestionItem): boolean {
|
||||
const sigA = optionSignature(a);
|
||||
const sigB = optionSignature(b);
|
||||
if (sigA && sigA === sigB) return true;
|
||||
const sa = questionPromptStem(a.prompt);
|
||||
const sb = questionPromptStem(b.prompt);
|
||||
if (!sa || !sb) return false;
|
||||
if (sa === sb) return true;
|
||||
return sa.includes(sb) || sb.includes(sa);
|
||||
}
|
||||
|
||||
export const SLOT_ASKED_QUESTIONS = "askedFollowUps";
|
||||
|
||||
/** 已问过的题目留痕(写进 slots 供跨 worker 去重) */
|
||||
export type AskedQuestionRecord = {
|
||||
prompt: string;
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
const ASKED_HISTORY_LIMIT = 40;
|
||||
|
||||
export function toAskedQuestionRecord(q: QuestionItem): AskedQuestionRecord {
|
||||
const options = (q.options ?? []).map((o) => o.label.trim()).filter(Boolean);
|
||||
return options.length ? { prompt: q.prompt, options } : { prompt: q.prompt };
|
||||
}
|
||||
|
||||
export function parseAskedQuestions(raw: unknown): AskedQuestionRecord[] {
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: typeof raw === "string" && raw.trim()
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out: AskedQuestionRecord[] = [];
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const prompt = typeof row.prompt === "string" ? row.prompt.trim() : "";
|
||||
if (!prompt) continue;
|
||||
const options = Array.isArray(row.options)
|
||||
? row.options.map((o) => String(o).trim()).filter(Boolean)
|
||||
: undefined;
|
||||
out.push(options?.length ? { prompt, options } : { prompt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function recordAsItem(rec: AskedQuestionRecord): QuestionItem {
|
||||
return {
|
||||
id: "asked",
|
||||
prompt: rec.prompt,
|
||||
options: rec.options?.map((label, i) => ({ id: letterId(i), label })),
|
||||
};
|
||||
}
|
||||
|
||||
export function wasAlreadyAsked(
|
||||
q: QuestionItem,
|
||||
history: readonly AskedQuestionRecord[],
|
||||
): boolean {
|
||||
return history.some((rec) => isSameFollowUpQuestion(q, recordAsItem(rec)));
|
||||
}
|
||||
|
||||
/** 过滤掉本局已经问过的题(用于可跳过的挂载追问) */
|
||||
export function dropAlreadyAskedQuestions(
|
||||
questions: readonly QuestionItem[],
|
||||
history: readonly AskedQuestionRecord[],
|
||||
): QuestionItem[] {
|
||||
if (!history.length) return [...questions];
|
||||
return questions.filter((q) => !wasAlreadyAsked(q, history));
|
||||
}
|
||||
|
||||
export function appendAskedQuestions(
|
||||
history: readonly AskedQuestionRecord[],
|
||||
questions: readonly QuestionItem[],
|
||||
): AskedQuestionRecord[] {
|
||||
const next = [...history];
|
||||
for (const q of questions) {
|
||||
if (wasAlreadyAsked(q, next)) continue;
|
||||
next.push(toAskedQuestionRecord(q));
|
||||
}
|
||||
return next.slice(-ASKED_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* askUser 与片段「追问」合并:同一问只留一份,优先带示例的片段题。
|
||||
* 仅片段没有的缺口才保留 askUser。
|
||||
*/
|
||||
export function mergeQuestionsPreferFragment(
|
||||
askUser: QuestionItem[] | undefined,
|
||||
fragment: QuestionItem[],
|
||||
): QuestionItem[] {
|
||||
if (!fragment.length) return askUser?.length ? [...askUser] : [];
|
||||
if (!askUser?.length) return [...fragment];
|
||||
|
||||
const usedAsk = new Set<number>();
|
||||
const out: QuestionItem[] = [];
|
||||
for (const fq of fragment) {
|
||||
const matchIdx = askUser.findIndex(
|
||||
(aq, i) => !usedAsk.has(i) && isSameFollowUpQuestion(aq, fq),
|
||||
);
|
||||
if (matchIdx >= 0) usedAsk.add(matchIdx);
|
||||
if (!out.some((q) => isSameFollowUpQuestion(q, fq))) out.push(fq);
|
||||
}
|
||||
for (let i = 0; i < askUser.length; i++) {
|
||||
if (usedAsk.has(i)) continue;
|
||||
const aq = askUser[i]!;
|
||||
if (out.some((q) => isSameFollowUpQuestion(q, aq))) continue;
|
||||
out.push(aq);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 发给 AI:必须含问题与答案;可选自由补充 */
|
||||
export function formatQuestionAnswersForAi(
|
||||
questions: QuestionItem[],
|
||||
|
||||
+15
-2
@@ -51,7 +51,13 @@ export type WaitingReason =
|
||||
questions: QuestionItem[];
|
||||
pageSize?: number;
|
||||
} // worker 无产物时的阻塞提问
|
||||
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败
|
||||
| { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑
|
||||
| {
|
||||
/** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */
|
||||
kind: "next_intent";
|
||||
afterWorkerId?: string;
|
||||
afterUnitId?: string;
|
||||
};
|
||||
|
||||
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
||||
export type SkillIndexEntry = {
|
||||
@@ -249,6 +255,11 @@ export type RuntimeEvent =
|
||||
questions: QuestionItem[] | string[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
/** 按意见重跑没写出新产物:退回上一版产物验收,重试追问改挂为可选 */
|
||||
type: "worker_revision_produced_nothing";
|
||||
payload: { artifactId: string; questions?: QuestionItem[] | string[] };
|
||||
}
|
||||
| {
|
||||
/** 验收态下作答/跳过挂载追问:不离开 review_artifact */
|
||||
type: "user_resolved_sidecar_questions";
|
||||
@@ -316,7 +327,9 @@ export type PhaseEffect =
|
||||
}
|
||||
| { type: "resume_worker" }
|
||||
| { type: "run_programmatic_review"; artifactId: string }
|
||||
| { type: "emit_message"; message: string };
|
||||
| { type: "emit_message"; message: string }
|
||||
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
|
||||
| { type: "propose_next_creation_step" };
|
||||
|
||||
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
|
||||
export type ApplyEventResult = {
|
||||
|
||||
+142
-27
@@ -15,10 +15,17 @@ import {
|
||||
} from "../skills/worker-set-parse.js";
|
||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||
import {
|
||||
mergeQuestionsPreferFragment,
|
||||
normalizeQuestions,
|
||||
type QuestionItem,
|
||||
} from "../skills/question-protocol.js";
|
||||
import { extractFragmentAskSidecar } from "../skills/context-fragment.js";
|
||||
import {
|
||||
extractFragmentAskSidecar,
|
||||
expectsContextFragmentTag,
|
||||
isUsableContextFragment,
|
||||
looksLikeFragmentDoc,
|
||||
} from "../skills/context-fragment.js";
|
||||
import { isProgressPointerTag } from "../skills/creation-flow.js";
|
||||
|
||||
export type WorkerRunParams = {
|
||||
skillName: string;
|
||||
@@ -66,7 +73,8 @@ askUser 每项可为:
|
||||
|
||||
- outputs 的 key 必须是要求的 outputTags
|
||||
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
|
||||
- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
|
||||
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
|
||||
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
|
||||
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
|
||||
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
||||
- summary 用于界面展示`;
|
||||
@@ -127,6 +135,102 @@ function gatherInputs(
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function stringifyOutputValue(val: unknown): string | undefined {
|
||||
if (typeof val === "string" && val.trim()) return val.trim();
|
||||
if (val && typeof val === "object") return JSON.stringify(val);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function productOutputTags(outputTags: string[]): string[] {
|
||||
return outputTags.filter((t) => t.trim() && !isProgressPointerTag(t));
|
||||
}
|
||||
|
||||
function assignOutput(
|
||||
outputs: Record<string, string>,
|
||||
tag: string | undefined,
|
||||
val: unknown,
|
||||
): void {
|
||||
if (!tag || isProgressPointerTag(tag)) return;
|
||||
const text = stringifyOutputValue(val);
|
||||
if (text) outputs[tag] = text;
|
||||
}
|
||||
|
||||
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
|
||||
function recoverFragmentOutputs(
|
||||
obj: Record<string, unknown>,
|
||||
outputs: Record<string, string>,
|
||||
outputTags: string[],
|
||||
): void {
|
||||
const target = productOutputTags(outputTags)[0];
|
||||
if (!target) return;
|
||||
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
|
||||
|
||||
const outputsRaw =
|
||||
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
|
||||
? (obj.outputs as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (outputsRaw) {
|
||||
for (const [key, val] of Object.entries(outputsRaw)) {
|
||||
if (key === target) continue;
|
||||
const text = stringifyOutputValue(val);
|
||||
if (!text) continue;
|
||||
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
|
||||
outputs[target] = text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const protocolKeys = new Set(["outputs", "summary", "askUser", "askAssessment"]);
|
||||
const rest: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (!protocolKeys.has(k)) rest[k] = v;
|
||||
}
|
||||
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
|
||||
const source = looksLikeFragmentDoc(obj) ? obj : rest;
|
||||
outputs[target] = JSON.stringify(source);
|
||||
}
|
||||
}
|
||||
|
||||
function dropUnusableFragmentOutputs(outputs: Record<string, string>): void {
|
||||
for (const tag of Object.keys(outputs)) {
|
||||
if (isProgressPointerTag(tag)) {
|
||||
delete outputs[tag];
|
||||
continue;
|
||||
}
|
||||
const content = outputs[tag]!;
|
||||
const requireFragment = expectsContextFragmentTag(tag);
|
||||
const looksFragment =
|
||||
isUsableContextFragment(content) ||
|
||||
looksLikeFragmentDoc(tryParseObject(content)) ||
|
||||
/"schema"\s*:\s*"context-fragment\.v1"/.test(content) ||
|
||||
/"技能"\s*:/.test(content);
|
||||
if ((requireFragment || looksFragment) && !isUsableContextFragment(content)) {
|
||||
delete outputs[tag];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseObject(text: string): unknown {
|
||||
const extracted = extractJsonObjectText(text) ?? text.trim();
|
||||
try {
|
||||
return JSON.parse(extracted);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
|
||||
{
|
||||
id: "design-step-retry",
|
||||
prompt:
|
||||
"这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。",
|
||||
allowOther: true,
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
|
||||
function parseWorkerResponse(
|
||||
raw: string,
|
||||
outputTags: string[],
|
||||
@@ -135,7 +239,6 @@ function parseWorkerResponse(
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// 整段不是协议 JSON:切勿把散文塞进设计.worker集*
|
||||
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
|
||||
const questions = normalizeQuestions(extractQuestionsFromText(raw));
|
||||
return {
|
||||
@@ -149,16 +252,20 @@ function parseWorkerResponse(
|
||||
]),
|
||||
};
|
||||
}
|
||||
const fallback: Record<string, string> = {};
|
||||
if (outputTags.length === 1) {
|
||||
fallback[outputTags[0]] = raw;
|
||||
} else {
|
||||
fallback[outputTags[0] ?? "output.草稿"] = raw;
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target && isUsableContextFragment(raw)) {
|
||||
return finalizeParsedOutputs(
|
||||
{ [target]: raw },
|
||||
{ summary: raw.slice(0, 80) },
|
||||
outputTags,
|
||||
);
|
||||
}
|
||||
const questions = normalizeQuestions(extractQuestionsFromText(raw));
|
||||
return {
|
||||
outputs: fallback,
|
||||
summary: raw.slice(0, 80),
|
||||
outputs: {},
|
||||
summary: "未产出可验收产物",
|
||||
preview: raw.slice(0, 600),
|
||||
askUser: questions.length ? questions.slice(0, 2) : INCOMPLETE_FRAGMENT_ASK,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,17 +278,20 @@ function parseWorkerResponse(
|
||||
const outputs: Record<string, string> = {};
|
||||
|
||||
if (outputsRaw && typeof outputsRaw === "object") {
|
||||
for (const tag of outputTags) {
|
||||
const val = (outputsRaw as Record<string, unknown>)[tag];
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
outputs[tag] = val.trim();
|
||||
} else if (val && typeof val === "object") {
|
||||
// 模型有时直接回对象而非字符串
|
||||
outputs[tag] = JSON.stringify(val);
|
||||
}
|
||||
for (const tag of productOutputTags(outputTags)) {
|
||||
assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
|
||||
}
|
||||
}
|
||||
recoverFragmentOutputs(obj, outputs, outputTags);
|
||||
|
||||
return finalizeParsedOutputs(outputs, obj, outputTags);
|
||||
}
|
||||
|
||||
function finalizeParsedOutputs(
|
||||
outputs: Record<string, string>,
|
||||
obj: Record<string, unknown>,
|
||||
_outputTags: string[],
|
||||
): WorkerRunResult {
|
||||
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
|
||||
|
||||
let askUser: QuestionItem[] | undefined;
|
||||
@@ -213,7 +323,7 @@ function parseWorkerResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// context-fragment.v1:正文内 追问/自评 → 挂到询问卡(不必再抄一份 askUser)
|
||||
// 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop
|
||||
let askAssessment: string | undefined;
|
||||
const fragQuestions: QuestionItem[] = [];
|
||||
for (const content of Object.values(outputs)) {
|
||||
@@ -226,19 +336,20 @@ function parseWorkerResponse(
|
||||
}
|
||||
}
|
||||
if (fragQuestions.length) {
|
||||
if (!askUser?.length) {
|
||||
askUser = fragQuestions;
|
||||
} else {
|
||||
for (const q of fragQuestions) {
|
||||
if (!askUser.some((x) => x.prompt === q.prompt)) askUser.push(q);
|
||||
}
|
||||
}
|
||||
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
|
||||
}
|
||||
|
||||
dropUnusableFragmentOutputs(outputs);
|
||||
|
||||
if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
|
||||
askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
|
||||
}
|
||||
|
||||
const summary =
|
||||
typeof obj.summary === "string" && obj.summary.trim()
|
||||
? obj.summary.trim()
|
||||
: Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成";
|
||||
: Object.values(outputs)[0]?.slice(0, 80) ??
|
||||
(askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成");
|
||||
|
||||
const preview =
|
||||
Object.entries(outputs)
|
||||
@@ -379,6 +490,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
} catch {
|
||||
acceptedStepNames = [];
|
||||
}
|
||||
const filledArtifactTags = params.blackboard
|
||||
.listTagIndex()
|
||||
.map((item) => item.tag);
|
||||
const loaded = await loadWorkerSkillWithContext(
|
||||
params.skillName,
|
||||
workerId,
|
||||
@@ -388,6 +502,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
currentStepName,
|
||||
acceptedStepNames,
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags,
|
||||
},
|
||||
);
|
||||
worker = loaded.worker;
|
||||
|
||||
Reference in New Issue
Block a user