重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 00:39:32 +08:00
co-authored by Cursor
parent 2b74c30d36
commit e670a5129c
167 changed files with 22955 additions and 5659 deletions
+236 -25
View File
@@ -1,10 +1,23 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import type { LlmProvider, StreamCallbacks } from "../llm/client.js";
import { supportsContentStream } from "../llm/stream-complete.js";
import { loadWorkerSkillWithContext } from "../skills/loader.js";
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
import { collectUserInputTranscript } from "../intake/intake.js";
import { resolveWorkerId } from "./resolve-id.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import type { ParsedWorkerSkill } from "../skills/types.js";
import {
extractJsonObjectText,
isUsableWorkerSet,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import {
normalizeQuestions,
type QuestionItem,
} from "../skills/question-protocol.js";
export type WorkerRunParams = {
skillName: string;
@@ -12,15 +25,25 @@ export type WorkerRunParams = {
slots: Record<string, unknown>;
blackboard: Blackboard;
llm: LlmProvider;
stream?: WorkerStreamCallbacks;
/** 声明驱动:跳过磁盘 SKILL,直接用解析好的契约 */
declared?: { worker: ParsedWorkerSkill; promptBody: string };
};
export type WorkerStreamCallbacks = {
onThinkingDelta?: (delta: string) => void;
onOutputDelta?: (delta: string) => void;
};
export type WorkerRunResult = {
outputs: Record<string, string>;
summary: string;
preview: string;
askUser?: string[];
askUser?: QuestionItem[];
};
const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]);
const WORKER_OUTPUT_INSTRUCTION = `
---
@@ -31,12 +54,18 @@ const WORKER_OUTPUT_INSTRUCTION = `
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 ["需要用户补充的问题"]
"askUser": null 或 问题数组
}
askUser 每项可为:
- 字符串:"需要用户补充的问题"
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
- outputs 的 key 必须是要求的 outputTags
- 若信息不足,outputs 可为空对象,askUser 填入问题
- 若 inputs 中 \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
function slotValueForTag(
@@ -82,6 +111,16 @@ function gatherInputs(
}
}
if (inputTags.includes("用户.需求") && !inputs["用户.需求"]) {
const fromSlot = slotValueForTag("用户.需求", slots);
if (fromSlot) {
inputs["用户.需求"] = fromSlot;
} else {
const transcript = collectUserInputTranscript(slots);
if (transcript) inputs["用户.需求"] = transcript;
}
}
return inputs;
}
@@ -93,6 +132,20 @@ 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 {
outputs: {},
summary: "未产出合法协议 JSON",
preview: raw.slice(0, 600),
askUser: questions.length
? questions.slice(0, 2)
: normalizeQuestions([
"请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。",
]),
};
}
const fallback: Record<string, string> = {};
if (outputTags.length === 1) {
fallback[outputTags[0]] = raw;
@@ -119,21 +172,26 @@ function parseWorkerResponse(
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);
}
}
}
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
let askUser: string[] | undefined;
let askUser: QuestionItem[] | undefined;
if (Array.isArray(obj.askUser)) {
askUser = obj.askUser.filter(
(q): q is string =>
typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q),
askUser = normalizeQuestions(
obj.askUser.filter((q) => {
if (typeof q === "string") return q.trim() && !isMetaAskToken(q);
return true;
}),
);
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
const q = obj.askUser.trim();
askUser = isMetaAskToken(q) ? undefined : [q];
askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]);
}
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
const summaryText =
@@ -144,9 +202,11 @@ function parseWorkerResponse(
summaryText.length > 8 &&
/[??]/.test(summaryText)
) {
askUser = [summaryText];
askUser = normalizeQuestions([summaryText]);
} else if (isMetaAskToken(summaryText)) {
askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
askUser = normalizeQuestions([
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。",
]);
}
}
@@ -161,15 +221,150 @@ function parseWorkerResponse(
.join("\n\n")
.slice(0, 4000) || summary;
return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined };
return sanitizeWorkerSetOutputs({
outputs,
summary,
preview,
askUser: askUser?.length ? askUser : undefined,
});
}
/** 规格 tag 必须是可用 JSON;非法散文改为 askUser,避免污染黑板 */
export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult {
const outputs = { ...result.outputs };
const askUser = [...(result.askUser ?? [])];
let droppedProse = false;
for (const tag of [...Object.keys(outputs)]) {
if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue;
const content = outputs[tag];
if (!content?.trim()) {
delete outputs[tag];
continue;
}
const extracted = extractJsonObjectText(content) ?? content.trim();
const parsed = parseWorkerSetYaml(extracted);
if (isUsableWorkerSet(parsed)) {
outputs[tag] = extracted.startsWith("{")
? extracted
: (extractJsonObjectText(extracted) ?? extracted);
continue;
}
droppedProse = true;
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
}
}
if (droppedProse && askUser.length === 0) {
askUser.push(
...normalizeQuestions([
"请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON)。",
]),
);
}
// 提问时不要夹带半残规格
if (askUser.length > 0) {
for (const tag of WORKER_SET_OUTPUT_TAGS) {
// 保留仍合法的草稿;已在上面删掉非法的
void tag;
}
}
const summary =
askUser.length && Object.keys(outputs).length === 0
? `待补充:${askUser[0]!.prompt.slice(0, 40)}`
: result.summary;
const preview =
askUser.length && Object.keys(outputs).length === 0
? askUser.map((q) => `- ${q.prompt}`).join("\n")
: Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
return {
outputs,
summary,
preview,
askUser: askUser.length ? askUser : undefined,
};
}
function extractQuestionsFromText(text: string): string[] {
const lines = text
.split(/\n+/)
.map((l) => l.replace(/^[-*•\d.、))]+\s*/, "").trim())
.filter((l) => l.length >= 6);
const withMark = lines.filter((l) => /[??]/.test(l) || /请(?:描述|选择|确认|补充)/.test(l));
if (withMark.length) return withMark.slice(0, 3);
// 整段像提问说明
if (/请(?:描述|选择|确认|补充)|你选择|或者你也可以/.test(text)) {
const compact = text.replace(/\s+/g, " ").trim().slice(0, 240);
if (compact) return [compact];
}
return [];
}
async function completeWorkerPreferStream(
llm: LlmProvider,
messages: Parameters<LlmProvider["complete"]>[0],
options: Parameters<LlmProvider["complete"]>[1],
stream?: WorkerStreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
const callbacks: StreamCallbacks = {
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
};
if (supportsContentStream(llm)) {
return llm.completeStream!(messages, options, callbacks);
}
const result = await llm.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId);
const { worker, promptBody } = await loadWorkerSkillWithContext(
params.skillName,
workerId,
);
let worker: ParsedWorkerSkill;
let promptBody: string;
if (params.declared) {
worker = params.declared.worker;
promptBody = params.declared.promptBody;
} else {
const flowRaw = params.blackboard.getContentByTag("设计.创作流程");
const currentStepName = params.blackboard.getContentByTag("创作.当前步骤");
const selectedRecipeRef = params.blackboard.getContentByTag("创作.选用配方");
const acceptedRaw =
params.slots.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
let acceptedStepNames: string[] = [];
try {
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
} catch {
acceptedStepNames = [];
}
const loaded = await loadWorkerSkillWithContext(
params.skillName,
workerId,
undefined,
{
flowRaw,
currentStepName,
acceptedStepNames,
selectedRecipeRef,
},
);
worker = loaded.worker;
promptBody = loaded.promptBody;
}
const inputMerge = worker.inputMerge ?? "latest";
let inputs = gatherInputs(
@@ -183,30 +378,46 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
inputs = filterInputsForRolePerspective(inputs, params.slots);
}
const userPayload = {
// 上一 worker 验收后的定稿摘要:始终注入,供本 worker「指点」用
const priorBrief = params.blackboard.getContentByTag(CONTEXT_BRIEF_TAG)?.trim();
if (priorBrief && !inputs[CONTEXT_BRIEF_TAG]) {
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
}
const userPayload = assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
workerId: worker.id,
workerName: worker.name,
inputTags: worker.inputTags,
outputTags: worker.outputTags,
inputs,
instruction:
"根据 SKILL 说明完成创作任务。若 inputs 不足,使用 askUser 提问而非臆造。inputs 中已有用户.博弈需求 / book.brief 时,应直接据此产出,勿重复索要已提供信息。",
};
});
const result = await params.llm.complete(
const result = await completeWorkerPreferStream(
params.llm,
[
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
{ role: "user", content: JSON.stringify(userPayload, null, 2) },
{ role: "user", content: userPayload },
],
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
},
params.stream,
);
return parseWorkerResponse(result.content, worker.outputTags);
}
/** @internal 供单测 */
export function parseWorkerResponseForTest(
raw: string,
outputTags: string[],
): WorkerRunResult {
return parseWorkerResponse(raw, outputTags);
}
/** @internal 供单测验证 inputTags → inputs 拼接 */
export function gatherWorkerInputs(
inputTags: string[],