Question
Question 系统允许 AI 向用户询问信息或选择。
概述
Question 提供了结构化的问答系统,AI 可以询问用户多选题、确认或输入。问题通过事件总线发送到 UI,用户回复后返回到 AI。
定义位置
packages/opencode/src/question/index.ts:11-32
主要对象
Option
问题选项定义。
| 属性名 | 类型 | 必填 | 说明 |
|---|---|---|---|
label | string | 是 | 显示文本(1-5 个词) |
description | string | 是 | 选项说明 |
Info
问题信息。
| 属性名 | 类型 | 必填 | 说明 |
|---|---|---|---|
question | string | 是 | 完整问题 |
header | string | 是 | 简短标签(最多 30 字符) |
options | Option[] | 是 | 可用选项列表 |
multiple | boolean | 否 | 是否允许多选 |
custom | boolean | 否 | 是否允许自定义输入(默认 true) |
Request
问题请求。
| 属性名 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 请求唯一标识符 |
sessionID | string | 是 | 会话 ID |
questions | Info[] | 是 | 要询问的问题列表 |
tool | object | 否 | 关联的工具信息 |
tool.messageID | string | - | 工具所在的消息 ID |
tool.callID | string | - | 工具调用 ID |
Answer
用户回答,每个问题对应一个答案数组。
type Answer = string[]
Reply
回复对象,包含所有问题的答案。
| 属性名 | 类型 | 必填 | 说明 |
|---|---|---|---|
answers | Answer[] | 是 | 按问题顺序的回答列表 |
TypeScript 类型定义
export type Option = {
label: string // 1-5 个词,简洁
description: string
}
export type Info = {
question: string
header: string // 最多 30 字符
options: Option[]
multiple?: boolean
custom?: boolean // 默认 true
}
export type Request = {
id: string
sessionID: string
questions: Info[]
tool?: {
messageID: string
callID: string
}
}
export type Answer = string[]
export type Reply = {
answers: Answer[] // 每个问题一个答案数组
}
事件
| 事件名 | 类型 | 说明 |
|---|---|---|
question.asked | request: Request | 询问问题 |
question.replied | {sessionID, requestID, answers} | 收到回答 |
question.rejected | {sessionID, requestID} | 用户拒绝 |
典型使用场景
1. 询问用户选择
const answers = await Question.ask({
sessionID: session.id,
questions: [
{
question: "请选择部署环境",
header: "部署环境",
options: [
{
label: "开发环境",
description: "部署到开发服务器",
},
{
label: "生产环境",
description: "部署到生产服务器",
},
],
multiple: false,
},
],
})
console.log(answers[0]) // ["开发环境"] 或 ["生产环境"]
2. 多选问题
const answers = await Question.ask({
sessionID: session.id,
questions: [
{
question: "选择要运行测试",
header: "测试套件",
options: [
{ label: "单元测试", description: "Jest 单元测试" },
{ label: "集成测试", description: "API 集成测试" },
{ label: "E2E 测试", description: "端到端测试" },
],
multiple: true, // 允许多选
},
],
})
console.log(answers[0]) // 可能是 ["单元测试", "集成测试"]
3. 多个问题
const answers = await Question.ask({
sessionID: session.id,
questions: [
{
question: "选择编程语言",
header: "语言",
options: [
{ label: "TypeScript", description: "类型安全" },
{ label: "JavaScript", description: "灵活" },
],
multiple: false,
},
{
question: "选择框架",
header: "框架",
options: [
{ label: "React", description: "组件化" },
{ label: "Vue", description: "响应式" },
],
multiple: false,
},
],
})
console.log(answers[0]) // ["TypeScript"]
console.log(answers[1]) // ["Vue"]
4. 关联工具调用
const answers = await Question.ask({
sessionID: session.id,
questions: [
{
question: "确认删除文件?",
header: "确认删除",
options: [
{ label: "是", description: "删除文件" },
{ label: "否", description: "取消操作" },
],
multiple: false,
},
],
tool: {
messageID: message.id,
callID: toolCallID,
},
})
// UI 可以显示工具上下文
5. 监听问题事件(UI 端)
Bus.subscribeAll(async (event) => {
if (event.type === Question.Event.Asked.type) {
const { info } = event.properties
console.log(`Question: ${info.questions.length} questions`)
for (const q of info.questions) {
console.log(` [${q.header}] ${q.question}`)
for (const opt of q.options) {
console.log(` - ${opt.label}: ${opt.description}`)
}
}
// 显示 UI 对话框
const userAnswers = await showQuestionDialog(info)
// 发送回复
await Question.reply({
requestID: info.id,
answers: userAnswers,
})
}
})
6. 列出待定问题
const pending = await Question.list()
for (const request of pending) {
console.log(`Pending question: ${request.id}`)
console.log(` Session: ${request.sessionID}`)
console.log(` Questions: ${request.questions.length}`)
}
7. 拒绝问题
// 用户点击取消
await Question.reject(requestID)
// 抛出 RejectedError
LLM 调用 question 的工作机制
OpenCode 中 LLM 通过以下步骤让 UI 调用并显示 question:
1. LLM 调用 question 工具
当 LLM 需要向用户提问时,会调用 question 工具:
// packages/opencode/src/tool/question.ts:6
export const QuestionTool = Tool.define("question", {
description: "Use this tool when you need to ask the user questions...",
parameters: z.object({
questions: z.array(Question.Info.omit({ custom: true })),
}),
async execute(params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
// ... 返回答案
},
})
LLM 如何知道调用 question 工具:
- 工具描述引导:
question工具的description明确说明使用场景:Use this tool when you need to ask the user questions during execution. This allows you to: 1. Gather user preferences or requirements 2. Clarify ambiguous instructions 3. Get decisions on implementation choices as you work 4. Offer choices to the user about what direction to take. - 工具注册:
QuestionTool在ToolRegistry.all()中注册(packages/opencode/src/tool/registry.ts:98) - 传递给 LLM:工具通过
SessionPrompt.resolveTools()获取并包装成 AI SDK 格式,传递给 LLM - 自主决策:LLM 基于任务需求和工具描述自主决定何时调用,无需额外 prompt 要求
2. QuestionTool 执行并发布事件
execute 方法调用 Question.ask()(packages/opencode/src/question/index.ts:97):
export async function ask(input: {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
// 存储 Promise 回调
s.pending[id] = {
info,
resolve,
reject,
}
// 发布 question.asked 事件
Bus.publish(Event.Asked, info)
})
}
关键步骤:
- 创建唯一的
requestID - 将问题信息和 Promise 回调存储到
pending状态 - 通过
Bus.publish(Event.Asked, info)发布question.asked事件
3. UI 监听并显示问题
UI 端监听 question.asked 事件并显示对话框:
Web UI(packages/app/src/context/global-sync.tsx:523):
case "question.asked": {
const sessionID = event.properties.sessionID
const questions = store.question[sessionID]
if (!questions) {
setStore("question", sessionID, [event.properties])
return
}
// 更新 store,触发 UI 更新
setStore("question", sessionID, reconcile(event.properties))
}
显示对话框(packages/ui/src/components/message-part.tsx:638):
const questionRequest = createMemo(() => {
const next = data.store.question?.[props.message.sessionID]?.[0]
if (!next || !next.tool) return undefined
if (next.tool!.callID !== part.callID) return undefined
return next
})
<Show when={showQuestion() && questionRequest()}>
{(request) => <QuestionPrompt request={request()} />}
</Show>
QuestionPrompt 组件显示问题的选项和输入界面。
4. 用户回答
用户选择答案后,UI 调用 data.replyToQuestion(packages/ui/src/components/message-part.tsx:1271):
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
data.replyToQuestion?.({
requestID: props.request.id,
answers,
})
}
5. 通过 API 回答问题
UI 通过 HTTP POST 调用 /question/:requestID/reply(packages/opencode/src/server/routes/question.ts:34):
.post("/:requestID/reply", async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
})
6. Promise resolve,LLM 继续执行
服务端调用 Question.reply()(packages/opencode/src/question/index.ts:123):
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) return
delete s.pending[input.requestID]
// 发布 question.replied 事件
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
// Resolve Promise,LLM 获得答案
existing.resolve(input.answers)
}
关键步骤:
- 从
pending状态中找到对应的 Promise - 发布
question.replied事件 - 调用
existing.resolve(),LLM 继续执行并获得答案
完整工作流程图
核心机制总结
- 事件驱动:使用 Bus 事件系统在服务端和 UI 之间通信
- Promise 等待:LLM 通过 Promise 等待用户回答,实现异步交互
- 状态存储:
pending状态存储未回答的问题和回调 - 双向通信:通过 HTTP API 完成用户→服务端的响应
- 工具描述引导:LLM 通过工具描述自主决定何时调用,无需额外 prompt
Question 生命周期
错误处理
RejectedError
当用户拒绝问题时抛出:
class RejectedError extends Error {
constructor() {
super("The user dismissed this question")
}
}
处理:
try {
const answers = await Question.ask({ ... })
} catch (error) {
if (error instanceof RejectedError) {
console.log("用户取消了问题")
} else {
throw error
}
}
自定义输入
当 custom: true(默认)时,用户可以输入自定义答案,不仅限于预定义选项。
{
question: "选择或输入文件路径",
header: "文件路径",
options: [
{ label: "package.json", description: "根目录配置" },
{ label: ".env", description: "环境变量" },
],
custom: true, // 允许输入自定义路径
}
最佳实践
- 简洁的标签: 选项标签应该 1-5 个词
- 清晰的描述: 提供详细的说明
- 合理的默认值: 使用
custom: true提供灵活性 - 分批问题: 避免一次问太多问题
- 关联工具: 在有意义的场景关联工具调用
对象关系
变更历史
| 版本 | 变更内容 | 日期 |
|---|---|---|
| v1 | 初始 Question 架构 | - |
| v1.1 | 添加 custom 字段支持 | - |