feat: 支持 AskUserQuestion 多问题和多选功能

- 新增 QuestionItem 类型支持单个问题配置(question/options/multiSelect)
   - AskUserEvent 改为 questions 数组支持多问题
   - AnswerRequest 新增 answers 字段支持多问题答案提交
   - 前端渲染支持单选按钮(radio)和多选复选框(checkbox)
   - 答案格式:{\"0\": [\"选项1\"], \"1\": [\"选项A\", \"选项B\"]}
   - 保持向后兼容旧的单问题格式
This commit is contained in:
Roe-xin
2026-03-05 16:58:59 +08:00
parent f6b1f5c45a
commit fa55e32153
7 changed files with 132 additions and 74 deletions

View File

@ -436,6 +436,7 @@ export async function showICHelperPanel(
message.askId, message.askId,
message.selected, message.selected,
message.customInput, message.customInput,
message.answers
); );
break; break;
// 新增:中止对话 // 新增:中止对话

View File

@ -43,8 +43,7 @@ export interface MessageSegment {
toolResult?: string; toolResult?: string;
toolDescription?: string; toolDescription?: string;
askId?: string; askId?: string;
question?: string; questions?: import("../types/api").QuestionItem[];
options?: string[];
// 智能体相关字段 // 智能体相关字段
agentId?: string; agentId?: string;
agentName?: string; agentName?: string;
@ -97,7 +96,7 @@ export interface DialogCallbacks {
summary: string summary: string
) => void; ) => void;
/** 显示问题ask_user */ /** 显示问题ask_user */
onQuestion?: (askId: string, question: string, options: string[]) => void; onQuestion?: (askId: string, questions: import("../types/api").QuestionItem[]) => void;
/** 实时更新段落(流式过程中) */ /** 实时更新段落(流式过程中) */
onSegmentUpdate?: (segments: MessageSegment[]) => void; onSegmentUpdate?: (segments: MessageSegment[]) => void;
/** 对话完成,返回所有段落 */ /** 对话完成,返回所有段落 */
@ -647,8 +646,11 @@ export class DialogSession {
this.segments.push({ this.segments.push({
type: "question", type: "question",
askId: askId, askId: askId,
question: question, questions: [{
options: ["确认执行", "取消"], question: question,
options: ["确认执行", "取消"],
multiSelect: false
}],
}); });
// 实时发送段落更新 // 实时发送段落更新
@ -666,8 +668,11 @@ export class DialogSession {
await userInteractionManager.handleAskUser( await userInteractionManager.handleAskUser(
{ {
askId: askId, askId: askId,
question: question, questions: [{
options: ["确认执行", "取消"], question: question,
options: ["确认执行", "取消"],
multiSelect: false
}]
} as AskUserEvent, } as AskUserEvent,
this.taskId this.taskId
); );
@ -714,8 +719,11 @@ export class DialogSession {
// 注册问题到前端(类似 askUser以便用户回答时能找到 // 注册问题到前端(类似 askUser以便用户回答时能找到
const planEvent = { const planEvent = {
askId: askId, askId: askId,
question: `请确认执行计划:${data.title}`, questions: [{
options: ["确认执行", "修改计划", "取消"], question: `请确认执行计划:${data.title}`,
options: ["确认执行", "修改计划", "取消"],
multiSelect: false
}]
}; };
try { try {
await userInteractionManager.handleAskUser( await userInteractionManager.handleAskUser(
@ -856,13 +864,12 @@ export class DialogSession {
this.segments.push({ this.segments.push({
type: "question", type: "question",
askId: data.askId, askId: data.askId,
question: data.question, questions: data.questions,
options: data.options,
}); });
// 实时发送段落更新(包含问题) // 实时发送段落更新(包含问题)
callbacks.onSegmentUpdate?.(this.segments); callbacks.onSegmentUpdate?.(this.segments);
// 同时调用 onQuestion 用于更新状态栏等 // 同时调用 onQuestion 用于更新状态栏等
callbacks.onQuestion?.(data.askId, data.question, data.options); callbacks.onQuestion?.(data.askId, data.questions);
try { try {
await userInteractionManager.handleAskUser(data, this.taskId); await userInteractionManager.handleAskUser(data, this.taskId);
} catch (error) { } catch (error) {
@ -1110,7 +1117,8 @@ export class DialogSession {
async submitAnswer( async submitAnswer(
askId: string, askId: string,
selected?: string[], selected?: string[],
customInput?: string customInput?: string,
answers?: { [questionIndex: string]: string[] }
): Promise<void> { ): Promise<void> {
// 直接调用 receiveAnswer传递 taskId 作为 fallbackTaskId // 直接调用 receiveAnswer传递 taskId 作为 fallbackTaskId
// 如果 pendingQuestions 中有问题,走正常流程 // 如果 pendingQuestions 中有问题,走正常流程
@ -1119,6 +1127,7 @@ export class DialogSession {
askId, askId,
selected, selected,
customInput, customInput,
answers,
this.taskId this.taskId
); );
} }

View File

@ -4,7 +4,7 @@
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { submitAnswer, submitToolConfirm } from './apiClient'; import { submitAnswer, submitToolConfirm } from './apiClient';
import type { AskUserEvent, AnswerRequest } from '../types/api'; import type { AskUserEvent, AnswerRequest, QuestionItem } from '../types/api';
/** /**
* 待处理的用户问题 * 待处理的用户问题
@ -12,8 +12,7 @@ import type { AskUserEvent, AnswerRequest } from '../types/api';
interface PendingQuestion { interface PendingQuestion {
askId: string; askId: string;
taskId: string; taskId: string;
question: string; questions: QuestionItem[];
options: string[];
resolve: (answer: string) => void; resolve: (answer: string) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
} }
@ -45,9 +44,9 @@ export class UserInteractionManager {
* @param taskId 当前任务ID * @param taskId 当前任务ID
*/ */
async handleAskUser(event: AskUserEvent, taskId: string): Promise<void> { async handleAskUser(event: AskUserEvent, taskId: string): Promise<void> {
const { askId, question, options } = event; const { askId, questions } = event;
console.log(`[UserInteraction] 收到问题: askId=${askId}, question=${question}`); console.log(`[UserInteraction] 收到问题: askId=${askId}, count=${questions.length}`);
// 注意:问题显示已经通过 dialogService 的 onSegmentUpdate 统一处理 // 注意:问题显示已经通过 dialogService 的 onSegmentUpdate 统一处理
// 这里不再单独发送 showQuestion 命令,避免重复显示 // 这里不再单独发送 showQuestion 命令,避免重复显示
@ -57,8 +56,7 @@ export class UserInteractionManager {
this.pendingQuestions.set(askId, { this.pendingQuestions.set(askId, {
askId, askId,
taskId, taskId,
question, questions,
options,
resolve: (answer: string) => { resolve: (answer: string) => {
this.submitUserAnswer(askId, taskId, answer) this.submitUserAnswer(askId, taskId, answer)
.then(() => resolve()) .then(() => resolve())
@ -80,24 +78,38 @@ export class UserInteractionManager {
/** /**
* 处理用户提交的回答(从 WebView 调用) * 处理用户提交的回答(从 WebView 调用)
* @param askId 问题ID * @param askId 问题ID
* @param selected 选中的选项 * @param selected 选中的选项(旧格式)
* @param customInput 自定义输入 * @param customInput 自定义输入(旧格式)
* @param answers 新格式:按问题索引的答案
* @param fallbackTaskId 当问题不存在时使用的 taskId用于直接发送到后端 * @param fallbackTaskId 当问题不存在时使用的 taskId用于直接发送到后端
*/ */
async receiveAnswer( async receiveAnswer(
askId: string, askId: string,
selected?: string[], selected?: string[],
customInput?: string, customInput?: string,
answers?: { [questionIndex: string]: string[] },
fallbackTaskId?: string fallbackTaskId?: string
): Promise<void> { ): Promise<void> {
const pending = this.pendingQuestions.get(askId); const pending = this.pendingQuestions.get(askId);
const answer = customInput || selected?.join(', ') || '';
// 构建答案字符串
let answer = '';
if (answers && Object.keys(answers).length > 0) {
// 新格式:多问题答案
answer = Object.entries(answers)
.sort(([a], [b]) => parseInt(a) - parseInt(b))
.map(([_, vals]) => vals.join('; '))
.join(' | ');
} else {
// 旧格式:单问题答案
answer = customInput || selected?.join(', ') || '';
}
if (!pending) { if (!pending) {
// 问题不存在(可能是页面刷新或会话切换后),尝试直接发送到后端 // 问题不存在(可能是页面刷新或会话切换后),尝试直接发送到后端
if (fallbackTaskId) { if (fallbackTaskId) {
console.log(`[UserInteraction] 问题不在 pendingQuestions 中,直接发送到后端: askId=${askId}, taskId=${fallbackTaskId}`); console.log(`[UserInteraction] 问题不在 pendingQuestions 中,直接发送到后端: askId=${askId}, taskId=${fallbackTaskId}`);
await this.submitUserAnswer(askId, fallbackTaskId, answer); await this.submitUserAnswer(askId, fallbackTaskId, answer, answers);
} else { } else {
console.warn(`[UserInteraction] 问题不存在且无 fallbackTaskId: askId=${askId}`); console.warn(`[UserInteraction] 问题不存在且无 fallbackTaskId: askId=${askId}`);
} }
@ -119,7 +131,8 @@ export class UserInteractionManager {
private async submitUserAnswer( private async submitUserAnswer(
askId: string, askId: string,
taskId: string, taskId: string,
answer: string answer: string,
answers?: { [questionIndex: string]: string[] }
): Promise<void> { ): Promise<void> {
// 检查是否是工具确认类型的问题 // 检查是否是工具确认类型的问题
if (askId.startsWith('tool_confirm_')) { if (askId.startsWith('tool_confirm_')) {
@ -148,7 +161,8 @@ export class UserInteractionManager {
const request: AnswerRequest = { const request: AnswerRequest = {
askId, askId,
taskId, taskId,
customInput: answer answers: answers,
customInput: answers ? undefined : answer
}; };
try { try {

View File

@ -194,11 +194,17 @@ export interface PlanSummaryUpdateEvent {
timestamp: number; timestamp: number;
} }
/** 单个问题项 */
export interface QuestionItem {
question: string;
options: string[];
multiSelect?: boolean;
}
/** ask_user 事件数据 */ /** ask_user 事件数据 */
export interface AskUserEvent { export interface AskUserEvent {
askId: string; askId: string;
question: string; questions: QuestionItem[];
options: string[];
} }
/** complete 事件数据 */ /** complete 事件数据 */
@ -351,10 +357,12 @@ export interface AnswerRequest {
askId: string; askId: string;
/** 任务ID */ /** 任务ID */
taskId: string; taskId: string;
/** 选中的选项列表 */ /** 选中的选项列表(旧格式,兼容) */
selected?: string[]; selected?: string[];
/** 自定义输入内容 */ /** 自定义输入内容(旧格式,兼容) */
customInput?: string; customInput?: string;
/** 新格式:按问题索引的答案 */
answers?: { [questionIndex: string]: string[] };
} }
/** 用户回答响应 */ /** 用户回答响应 */

View File

@ -469,10 +469,11 @@ async function handleUserMessageWithBackend(
export async function handleUserAnswer( export async function handleUserAnswer(
askId: string, askId: string,
selected?: string[], selected?: string[],
customInput?: string customInput?: string,
answers?: { [questionIndex: string]: string[] }
): Promise<void> { ): Promise<void> {
if (currentSession) { if (currentSession) {
await currentSession.submitAnswer(askId, selected, customInput); await currentSession.submitAnswer(askId, selected, customInput, answers);
} }
} }

View File

@ -129,7 +129,8 @@ export function showICHelperPanel(context: vscode.ExtensionContext) {
handleUserAnswer( handleUserAnswer(
message.askId, message.askId,
message.selected, message.selected,
message.customInput message.customInput,
message.answers
); );
break; break;
// 新增:中止对话 // 新增:中止对话

View File

@ -1188,64 +1188,60 @@ export function getMessageAreaScript(): string {
} else if (segment.type === 'question') { } else if (segment.type === 'question') {
segmentDiv.className += ' segment-question'; segmentDiv.className += ' segment-question';
// 兼容旧格式:如果有 segment.question转换为 questions 数组
const questions = segment.questions || (segment.question ? [{
question: segment.question,
options: segment.options || [],
multiSelect: false
}] : []);
// 检查是否已回答 // 检查是否已回答
const isAnswered = answeredQuestions.has(segment.askId); const isAnswered = answeredQuestions.has(segment.askId);
const selectedAnswer = answeredQuestions.get(segment.askId); const savedAnswers = answeredQuestions.get(segment.askId) || {};
if (isAnswered) { if (isAnswered) {
segmentDiv.classList.add('answered'); segmentDiv.classList.add('answered');
} }
// 检查是否有选项 // 渲染多个问题
const hasOptions = segment.options && segment.options.length > 0; const questionsHtml = questions.map((q, qIndex) => {
const inputType = q.multiSelect ? 'checkbox' : 'radio';
const inputName = \`q\${qIndex}\`;
const selectedAnswers = savedAnswers[qIndex] || [];
const optionsHtml = hasOptions const optionsHtml = q.options.map(opt => {
? (segment.options || []).map(opt => { const isSelected = selectedAnswers.includes(opt);
const isSelected = isAnswered && opt === selectedAnswer; return \`<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:4px 0;">
return \`<button class="question-option\${isSelected ? ' selected' : ''}" data-option="\${opt}">\${opt}</button>\`; <input type="\${inputType}" name="\${inputName}" value="\${opt}" \${isSelected ? 'checked' : ''} \${isAnswered ? 'disabled' : ''}>
}).join('') <span>\${opt}</span>
: ''; </label>\`;
}).join('');
return \`
<div class="question-item" data-question-index="\${qIndex}" style="margin-bottom:12px;">
<div class="question-text" style="margin-bottom:8px;">\${formatText(q.question)}</div>
<div class="question-options">\${optionsHtml}</div>
</div>
\`;
}).join('');
segmentDiv.innerHTML = \` segmentDiv.innerHTML = \`
<div class="question-text">\${formatText(segment.question || '')}</div> \${questionsHtml}
\${hasOptions ? \`<div class="question-options" data-ask-id="\${segment.askId}">\${optionsHtml}</div>\` : ''} <button class="custom-submit" style="display:\${isAnswered ? 'none' : 'block'};margin-top:8px;padding:8px 16px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);border:none;border-radius:6px;cursor:pointer;">提交答案</button>
<div class="custom-input-container" style="display: \${isAnswered ? 'none' : 'flex'};">
<input type="text" class="custom-input" placeholder="\${hasOptions ? '输入其他答案...' : '请输入您的答案...'}" />
<button class="custom-submit">提交</button>
</div>
\`; \`;
// 只在未回答时添加事件监听 // 只在未回答时添加事件监听
if (!isAnswered) { if (!isAnswered) {
setTimeout(() => { setTimeout(() => {
if (hasOptions) {
const optionButtons = segmentDiv.querySelectorAll('.question-option');
optionButtons.forEach(btn => {
btn.addEventListener('click', function() {
const option = this.getAttribute('data-option');
handleQuestionAnswerInSegment(segment.askId, option, segmentDiv);
});
});
}
const submitBtn = segmentDiv.querySelector('.custom-submit'); const submitBtn = segmentDiv.querySelector('.custom-submit');
const customInput = segmentDiv.querySelector('.custom-input'); if (submitBtn) {
if (submitBtn && customInput) {
submitBtn.addEventListener('click', function() { submitBtn.addEventListener('click', function() {
const customValue = customInput.value.trim(); const answers = {};
if (customValue) { questions.forEach((q, qIndex) => {
handleQuestionAnswerInSegment(segment.askId, customValue, segmentDiv); const inputs = segmentDiv.querySelectorAll(\`input[name="q\${qIndex}"]:checked\`);
} answers[qIndex] = Array.from(inputs).map(input => input.value);
}); });
handleMultiQuestionAnswer(segment.askId, answers, segmentDiv);
// 支持回车提交
customInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
const customValue = customInput.value.trim();
if (customValue) {
handleQuestionAnswerInSegment(segment.askId, customValue, segmentDiv);
}
}
}); });
} }
}, 0); }, 0);
@ -1723,6 +1719,34 @@ export function getMessageAreaScript(): string {
}); });
} }
// 处理多问题答案提交
function handleMultiQuestionAnswer(askId, answers, segmentDiv) {
console.log('[WebView] 多问题答案提交:', askId, answers);
// 保存答案到 Map 中
answeredQuestions.set(askId, answers);
// 标记问题已回答
segmentDiv.classList.add('answered');
// 禁用所有输入
const inputs = segmentDiv.querySelectorAll('input');
inputs.forEach(input => input.disabled = true);
// 隐藏提交按钮
const submitBtn = segmentDiv.querySelector('.custom-submit');
if (submitBtn) {
submitBtn.style.display = 'none';
}
// 发送答案到后端
vscode.postMessage({
command: 'submitAnswer',
askId: askId,
answers: answers
});
}
${getWaveformPreviewScript()} ${getWaveformPreviewScript()}
${getCodeHighlightScript()} ${getCodeHighlightScript()}