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

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

535 lines
19 KiB
JavaScript
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.
/**
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
* 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
*
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
*/
/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */
export const MODULE_OPENING_QUESTION_ID = "module-opening";
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */
export function isModuleOpeningWaiting(view) {
const wr = view?.waitingReason;
if (wr?.kind !== "worker_questions" || !Array.isArray(wr.questions)) return false;
const qs = normalizeQuestions(wr.questions);
return qs.length === 1 && qs[0].id === MODULE_OPENING_QUESTION_ID;
}
/** 取出默认问题正文(供 openingGuide / 说话面展示) */
export function getModuleOpeningPrompt(view) {
if (!isModuleOpeningWaiting(view)) return null;
const qs = normalizeQuestions(view.waitingReason.questions);
const prompt = qs[0]?.prompt?.trim();
return prompt || null;
}
function normalizeQuestions(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((item, i) => {
if (typeof item === "string") {
const prompt = item.trim();
if (!prompt) return null;
return { id: `q${i + 1}`, prompt, allowOther: true, required: false, options: [] };
}
if (!item || typeof item !== "object") return null;
const prompt = String(item.prompt ?? item.question ?? item.text ?? "").trim();
if (!prompt) return null;
const options = Array.isArray(item.options)
? item.options
.map((o, j) => {
if (typeof o === "string") {
const label = o.trim();
return label
? { id: String.fromCharCode(65 + (j % 26)), label, editable: true }
: null;
}
if (!o || typeof o !== "object") return null;
const label = String(o.label ?? o.text ?? "").trim();
if (!label) return null;
return {
id: String(o.id ?? String.fromCharCode(65 + (j % 26))),
label,
editable: o.editable === false ? false : true,
};
})
.filter(Boolean)
: [];
return {
id: String(item.id ?? `q${i + 1}`),
prompt,
options,
allowOther: item.allowOther === false ? false : true,
required: item.required === true ? true : false,
};
})
.filter(Boolean);
}
/** @returns {{ questions: any[], workerId?: string, pageSize: number, assessment?: string, optional?: boolean, skipLabel?: string } | null} */
export function getActiveQuestions(view) {
const wr = view?.waitingReason;
if (!wr) return null;
// 默认每页 1 题,左右切换;勿一次堆多题
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
if (wr.kind === "worker_questions" && wr.questions?.length) {
// 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形)
if (isModuleOpeningWaiting(view)) return null;
return {
questions: normalizeQuestions(wr.questions),
workerId: wr.workerId,
pageSize,
optional: true,
skipLabel: "跳过",
};
}
if (wr.kind === "review_artifact" && wr.questions?.length) {
const assessment =
typeof wr.assessment === "string" && wr.assessment.trim()
? wr.assessment.trim()
: "";
return {
questions: normalizeQuestions(wr.questions).map((q) => ({
...q,
required: false,
})),
workerId: view.reviewArtifact?.workerId,
pageSize,
assessment,
optional: true,
skipLabel: "跳过",
dismissOnAccept: true,
};
}
if (wr.kind === "input" && wr.questions?.length) {
const assessment =
typeof wr.message === "string" && wr.message.trim()
? wr.message.trim()
: "";
// 短调度句不当作内容评价展示
const showAssessment =
assessment.length > 80 ||
/完备度|核心体验|已知|待探|#\s/.test(assessment);
return {
questions: normalizeQuestions(wr.questions).map((q) => ({
...q,
required: q.required === true ? true : false,
})),
workerId: "orchestrator",
pageSize,
assessment: showAssessment ? assessment : "",
optional: true,
skipLabel: "跳过",
};
}
return null;
}
/**
* 本轮题是否已答复收起(卡不会再画出来)。
* 此时若仍把主区排成答题面,用户只会看到一片空壳。
*/
export function isQuestionCardDismissed(view, host) {
const el = host ?? document.getElementById("questions-card-host");
const active = getActiveQuestions(view);
if (!el?._qDismissed || !active?.questions?.length) return false;
const key = JSON.stringify(active.questions.map((q) => q.id));
return el._qDismissed.sessionId === view?.id && el._qDismissed.key === key;
}
function persistDraftsFromDom(host, state) {
host.querySelectorAll("[data-draft]").forEach((el) => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
});
host.querySelectorAll("[data-other]").forEach((el) => {
const qid = el.getAttribute("data-other");
if (!qid) return;
const prev = state.answers[qid] ?? {};
state.answers[qid] = { ...prev, otherText: el.value };
});
}
/**
* 从询问卡收集并校验全部作答(供 composer 发送时调用)。
* @returns {{ ok: true, answers: Array<{questionId:string, optionId?:string, text:string}> } | { ok: false, error: string, page?: number }}
*/
export function collectQuestionAnswers(host, view) {
const active = getActiveQuestions(view);
if (!active?.questions?.length) {
return { ok: false, error: "当前没有待回答的追问" };
}
if (!host?._qState) {
return { ok: false, error: "请先在询问卡中选择选项" };
}
const state = host._qState;
persistDraftsFromDom(host, state);
const { questions, pageSize } = active;
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (q.required === false) continue;
const ans = state.answers[q.id];
if (!ans?.optionId) {
return {
ok: false,
error: `请先选择第 ${i + 1} 题的选项(点字母选中)`,
page: Math.floor(i / pageSize),
};
}
if (ans.optionId === "other") {
const text = (ans.otherText ?? ans.text ?? "").trim();
if (!text) {
return {
ok: false,
error: `请填写第 ${i + 1} 题的 Other`,
page: Math.floor(i / pageSize),
};
}
state.answers[q.id] = { optionId: "other", text, otherText: text };
} else {
const draftKey = `${q.id}:${ans.optionId}`;
const text = (state.drafts[draftKey] ?? ans.text ?? "").trim();
if (!text) {
return {
ok: false,
error: `${i + 1} 题选项文案不能为空`,
page: Math.floor(i / pageSize),
};
}
state.answers[q.id] = { ...ans, text };
}
}
const answers = questions.map((q) => {
const ans = state.answers[q.id];
if (!ans?.optionId) {
return { questionId: q.id, text: "(未答)" };
}
if (ans.optionId === "other") {
return {
questionId: q.id,
optionId: "other",
text: (ans.otherText ?? ans.text ?? "").trim() || "(未答)",
};
}
const draftKey = `${q.id}:${ans.optionId}`;
const text = (state.drafts[draftKey] ?? ans.text ?? "").trim();
return {
questionId: q.id,
optionId: ans.optionId,
text: text || "(未答)",
};
});
return { ok: true, answers };
}
/**
* 收起询问卡。传入 view 时标记本轮题已答复,避免 loading 用旧 waitingReason 把卡又画回来。
* @param {HTMLElement | null | undefined} host
* @param {object} [view]
*/
export function clearQuestionCardState(host, view) {
if (!host) return;
const active = view ? getActiveQuestions(view) : null;
if (active?.questions?.length && view?.id) {
host._qDismissed = {
sessionId: view.id,
key: JSON.stringify(active.questions.map((q) => q.id)),
};
} else if (host._qState?.sessionId && host._qState?.key) {
host._qDismissed = {
sessionId: host._qState.sessionId,
key: host._qState.key,
};
}
host._qState = null;
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
}
/**
* @param {HTMLElement} host
* @param {object} view
* @param {{}} [_handlers]
*/
export function renderQuestionsCard(host, view, _handlers = {}) {
if (!host) return false;
const active = getActiveQuestions(view);
if (!active?.questions?.length) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
host._qDismissed = null;
return false;
}
const qKey = JSON.stringify(active.questions.map((q) => q.id));
// 本轮已通过发送/跳过答复 → 在 waitingReason 清掉前也不再打开
if (
host._qDismissed?.sessionId === view.id &&
host._qDismissed?.key === qKey
) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
return false;
}
host.hidden = false;
host.classList.add("is-open");
const state = host._qState?.sessionId === view.id && host._qState?.key === qKey
? host._qState
: {
sessionId: view.id,
key: qKey,
page: 0,
expanded: false,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
if (typeof state.expanded !== "boolean") state.expanded = false;
host._qState = state;
host.classList.toggle("is-expanded", state.expanded);
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active;
const pageCount = Math.max(1, Math.ceil(questions.length / pageSize));
if (state.page >= pageCount) state.page = pageCount - 1;
if (state.page < 0) state.page = 0;
const start = state.page * pageSize;
const pageQs = questions.slice(start, start + pageSize);
const assessmentHtml =
assessment && state.page === 0
? `<div class="qcard-assessment">${esc(assessment)}</div>`
: "";
const dismissNote =
dismissOnAccept && state.page === 0
? `<p class="qcard-optional-note">可选 · 接受产物即收起(无需再完善)</p>`
: optional && state.page === 0
? `<p class="qcard-optional-note">可选 · 可跳过继续</p>`
: "";
const pagerHtml =
pageCount > 1
? `<div class="qcard-pager" aria-label="问题切换">
<button type="button" class="qcard-nav" data-qnav="prev" ${state.page <= 0 ? "disabled" : ""} aria-label="上一题"></button>
<span class="qcard-page">${state.page + 1}/${pageCount}</span>
<button type="button" class="qcard-nav" data-qnav="next" ${state.page >= pageCount - 1 ? "disabled" : ""} aria-label="下一题"></button>
</div>`
: "";
const skipHtml = optional
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
: "";
const chromeMods = [
pageCount > 1 ? "has-pager" : "",
optional ? "has-skip" : "",
].filter(Boolean);
host.classList.remove("has-pager", "has-skip");
for (const c of chromeMods) host.classList.add(c);
host.setAttribute("role", "dialog");
host.setAttribute("aria-label", "询问");
host.innerHTML = `
<div class="qcard-float">
${pagerHtml}
${skipHtml}
</div>
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
</button>
<div class="qcard-body">
${dismissNote}
${assessmentHtml}
${pageQs
.map((q, qi) => {
const globalIdx = start + qi + 1;
const ans = state.answers[q.id] ?? {};
const opts = q.options?.length
? q.options
: q.allowOther
? []
: [{ id: "A", label: q.prompt, editable: true }];
return `
<section class="qcard-q" data-qid="${esc(q.id)}">
<div class="qcard-prompt">${globalIdx}. ${esc(q.prompt)}</div>
<ul class="qcard-options">
${opts
.map((opt, oi) => {
const letter = String.fromCharCode(65 + (oi % 26));
const draftKey = `${q.id}:${opt.id}`;
const label = state.drafts[draftKey] ?? opt.label;
const selected = ans.optionId === opt.id;
const editable = opt.editable !== false;
return `
<li class="qcard-opt${selected ? " is-selected" : ""}" data-oid="${esc(opt.id)}">
<button type="button" class="qcard-letter" data-select="${esc(opt.id)}" title="选中">${letter}</button>
${
editable
? `<div class="qcard-label" contenteditable="true" data-draft="${esc(draftKey)}" spellcheck="false">${esc(label)}</div>`
: `<div class="qcard-label is-readonly">${esc(label)}</div>`
}
</li>`;
})
.join("")}
${
q.allowOther !== false
? `
<li class="qcard-opt qcard-other${ans.optionId === "other" ? " is-selected" : ""}" data-oid="other">
<button type="button" class="qcard-letter" data-select="other" title="选中">+</button>
<input class="qcard-other-input" type="text" placeholder="Other…" data-other="${esc(q.id)}" value="${esc(ans.otherText ?? "")}" />
</li>`
: ""
}
</ul>
</section>`;
})
.join("")}
</div>`;
const setExpanded = (next) => {
state.expanded = next;
host.classList.toggle("is-expanded", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "点击收起" : "点击展开";
top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡");
}
};
host.querySelector("[data-qtop]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!state.expanded);
});
host.querySelector(".qcard-body")?.addEventListener("click", (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (
t.closest(
".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager",
)
) {
return;
}
// 展开后点题干 / 评估区收起;收起时点内容区展开
if (state.expanded) {
if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) {
setExpanded(false);
}
return;
}
setExpanded(true);
});
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
_handlers.onSkipQuestions?.();
});
host.querySelectorAll("[data-qnav]").forEach((btn) => {
btn.addEventListener("click", () => {
persistDraftsFromDom(host, state);
const dir = btn.getAttribute("data-qnav");
if (dir === "prev" && state.page > 0) state.page -= 1;
if (dir === "next" && state.page < pageCount - 1) state.page += 1;
renderQuestionsCard(host, view, _handlers);
});
});
host.querySelectorAll("[data-select]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
persistDraftsFromDom(host, state);
const section = btn.closest(".qcard-q");
const qid = section?.getAttribute("data-qid");
const oid = btn.getAttribute("data-select");
if (!qid || !oid) return;
let text = "";
if (oid === "other") {
const input = section.querySelector(`[data-other="${qid}"]`);
text = (input?.value ?? "").trim();
state.answers[qid] = { optionId: "other", text, otherText: text };
} else {
const draftKey = `${qid}:${oid}`;
const labelEl = section.querySelector(`[data-draft="${draftKey}"]`);
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
state.answers[qid] = { optionId: oid, text };
}
// 只切换选中态,避免整卡 innerHTML 重绘把 .qcard-body 滚回顶部
section.querySelectorAll(".qcard-opt").forEach((li) => {
li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid);
});
if (!state.expanded) setExpanded(true);
_handlers.onAnswersChange?.();
});
});
host.querySelectorAll("[data-other]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
});
host.querySelectorAll("[data-draft]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
el.addEventListener("blur", () => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
const section = el.closest(".qcard-q");
const qid = section?.getAttribute("data-qid");
const oid = el.closest(".qcard-opt")?.getAttribute("data-oid");
if (qid && oid && state.answers[qid]?.optionId === oid) {
state.answers[qid] = {
...state.answers[qid],
text: (el.textContent ?? "").trim(),
};
}
});
});
return true;
}