/** * 结构化询问卡:点字母选中,点文案编辑;左右分页。 * 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。 * 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。 * 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。 * 分页 / 跳过为悬浮控件,不占独立 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, "&") .replace(//g, ">") .replace(/"/g, """); } /** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */ 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 ? `
${esc(assessment)}
` : ""; const dismissNote = dismissOnAccept && state.page === 0 ? `

可选 · 接受产物即收起(无需再完善)

` : optional && state.page === 0 ? `

可选 · 可跳过继续

` : ""; const pagerHtml = pageCount > 1 ? `
${state.page + 1}/${pageCount}
` : ""; const skipHtml = optional ? `` : ""; 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 = `
${pagerHtml} ${skipHtml}
${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 `
${globalIdx}. ${esc(q.prompt)}
`; }) .join("")}
`; 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; }