完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。

- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。
- 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。
- 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。
- 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。
- 同步世界模拟器模块提示、编排文档与相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 01:28:12 +08:00
parent 20938b04d7
commit ec474cb946
56 changed files with 7460 additions and 1372 deletions

View File

@@ -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 优先 brieffields 尝试列出正文顶层键 */
export function projectFragmentContent(
raw: string,

View File

@@ -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");
}

View File

@@ -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 } : {}),
};

View File

@@ -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[];

View File

@@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: {
currentStepName,
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef,
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
},
);
return {

View File

@@ -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,

View File

@@ -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[],