Files
writing-agent/web/questions-ui.js
T
chenmoranzhiandCursor 92b2d88738 Play rounds end in present.v1 body instead of creation HITL.
Narrator uses capability-probed structured delivery and falls back to shell body on failure; play UI hides worker questions and keeps thinking on process rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 01:07:43 +08:00

522 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) {
if (view?.playLayerActive || view?.lifecycleStage === "play") return null;
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", "is-collapsed", "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", "is-collapsed", "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", "is-collapsed", "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,
collapsed: false,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
if (typeof state.collapsed !== "boolean") state.collapsed = false;
host._qState = state;
host.classList.toggle("is-collapsed", state.collapsed);
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.collapsed ? "展开追问" : "折叠追问"}" aria-expanded="${state.collapsed ? "false" : "true"}" aria-label="${state.collapsed ? "展开追问" : "折叠追问"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
<span class="qcard-collapsed-meta">${esc(
pageCount > 1
? `追问 ${state.page + 1}/${pageCount} · ${pageQs[0]?.prompt ?? "询问"}`
: `追问 · ${pageQs[0]?.prompt ?? "询问"}`,
)}</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 setCollapsed = (next) => {
state.collapsed = next;
host.classList.toggle("is-collapsed", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "展开追问" : "折叠追问";
top.setAttribute("aria-expanded", next ? "false" : "true");
top.setAttribute("aria-label", next ? "展开追问" : "折叠追问");
}
};
host.querySelector("[data-qtop]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setCollapsed(!state.collapsed);
});
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);
});
_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;
}