- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。 - 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。 - 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。 - 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。 - 同步世界模拟器模块提示、编排文档与相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
/**
|
||
* Worker / Agent 结构化追问协议。
|
||
* UI:左右分页选项卡;经 composer 发送,payload 须含问+答(可附自由补充)。
|
||
*/
|
||
import type {
|
||
QuestionAnswer,
|
||
QuestionItem,
|
||
QuestionOption,
|
||
} from "../types/questions.js";
|
||
|
||
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));
|
||
}
|
||
|
||
/** 把 askUser 原始值规范成 QuestionItem[](兼容纯 string[]) */
|
||
export function normalizeQuestions(raw: unknown): QuestionItem[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
const out: QuestionItem[] = [];
|
||
for (let i = 0; i < raw.length; i++) {
|
||
const item = raw[i];
|
||
if (typeof item === "string") {
|
||
const prompt = item.trim();
|
||
if (!prompt) continue;
|
||
out.push({
|
||
id: `q${i + 1}`,
|
||
prompt,
|
||
allowOther: true,
|
||
required: true,
|
||
});
|
||
continue;
|
||
}
|
||
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()
|
||
: typeof row.question === "string"
|
||
? row.question.trim()
|
||
: typeof row.text === "string"
|
||
? row.text.trim()
|
||
: "";
|
||
if (!prompt) continue;
|
||
const id =
|
||
typeof row.id === "string" && row.id.trim()
|
||
? row.id.trim()
|
||
: `q${i + 1}`;
|
||
let options: QuestionOption[] | undefined;
|
||
if (Array.isArray(row.options)) {
|
||
options = [];
|
||
for (let j = 0; j < row.options.length; j++) {
|
||
const opt = row.options[j];
|
||
if (typeof opt === "string") {
|
||
const label = opt.trim();
|
||
if (!label) continue;
|
||
options.push({ id: letterId(j), label, editable: true });
|
||
continue;
|
||
}
|
||
if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue;
|
||
const o = opt as Record<string, unknown>;
|
||
const label =
|
||
typeof o.label === "string"
|
||
? o.label.trim()
|
||
: typeof o.text === "string"
|
||
? o.text.trim()
|
||
: "";
|
||
if (!label) continue;
|
||
options.push({
|
||
id:
|
||
typeof o.id === "string" && o.id.trim()
|
||
? o.id.trim()
|
||
: letterId(j),
|
||
label,
|
||
editable: o.editable === false ? false : true,
|
||
});
|
||
}
|
||
if (options.length === 0) options = undefined;
|
||
}
|
||
out.push({
|
||
id,
|
||
prompt,
|
||
options,
|
||
allowOther: row.allowOther === false ? false : true,
|
||
required: row.required === false ? false : true,
|
||
});
|
||
}
|
||
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[],
|
||
answers: QuestionAnswer[],
|
||
note?: string,
|
||
): string {
|
||
const byId = new Map(answers.map((a) => [a.questionId, a]));
|
||
const lines: string[] = ["【追问作答】"];
|
||
for (const q of questions) {
|
||
const a = byId.get(q.id);
|
||
const answer = a?.text?.trim() || "(未答)";
|
||
lines.push(`问:${q.prompt}`);
|
||
lines.push(`答:${answer}`);
|
||
lines.push("");
|
||
}
|
||
const trimmedNote = note?.trim();
|
||
if (trimmedNote) {
|
||
lines.push("【补充】");
|
||
lines.push(trimmedNote);
|
||
}
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
/** 气泡摘要:答句 + 可选补充 */
|
||
export function formatQuestionAnswersForDisplay(
|
||
questions: QuestionItem[],
|
||
answers: QuestionAnswer[],
|
||
note?: string,
|
||
): string {
|
||
const byId = new Map(answers.map((a) => [a.questionId, a]));
|
||
const parts: string[] = [];
|
||
for (const q of questions) {
|
||
const a = byId.get(q.id);
|
||
if (!a?.text?.trim()) continue;
|
||
parts.push(a.text.trim());
|
||
}
|
||
const trimmedNote = note?.trim();
|
||
if (trimmedNote) parts.push(trimmedNote);
|
||
return parts.length ? parts.join("\n") : "(已提交追问作答)";
|
||
}
|
||
|
||
export function questionPrompts(questions: QuestionItem[]): string[] {
|
||
return questions.map((q) => q.prompt).filter(Boolean);
|
||
}
|