完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。
- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。 - 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。 - 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。 - 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。 - 同步世界模拟器模块提示、编排文档与相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user