完善创作节点循环与等待态交互,并大幅打磨 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

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