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