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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 01:28:12 +08:00

278 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}