重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+236
-25
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user