Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • Question

Question

Question 系统允许 AI 向用户询问信息或选择。

概述

Question 提供了结构化的问答系统,AI 可以询问用户多选题、确认或输入。问题通过事件总线发送到 UI,用户回复后返回到 AI。

定义位置

packages/opencode/src/question/index.ts:11-32

主要对象

Option

问题选项定义。

属性名类型必填说明
labelstring是显示文本(1-5 个词)
descriptionstring是选项说明

Info

问题信息。

属性名类型必填说明
questionstring是完整问题
headerstring是简短标签(最多 30 字符)
optionsOption[]是可用选项列表
multipleboolean否是否允许多选
customboolean否是否允许自定义输入(默认 true)

Request

问题请求。

属性名类型必填说明
idstring是请求唯一标识符
sessionIDstring是会话 ID
questionsInfo[]是要询问的问题列表
toolobject否关联的工具信息
tool.messageIDstring-工具所在的消息 ID
tool.callIDstring-工具调用 ID

Answer

用户回答,每个问题对应一个答案数组。

type Answer = string[]

Reply

回复对象,包含所有问题的答案。

属性名类型必填说明
answersAnswer[]是按问题顺序的回答列表

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.askedrequest: 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)
  })
}

关键步骤:

  1. 创建唯一的 requestID
  2. 将问题信息和 Promise 回调存储到 pending 状态
  3. 通过 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)
}

关键步骤:

  1. 从 pending 状态中找到对应的 Promise
  2. 发布 question.replied 事件
  3. 调用 existing.resolve(),LLM 继续执行并获得答案

完整工作流程图

核心机制总结

  1. 事件驱动:使用 Bus 事件系统在服务端和 UI 之间通信
  2. Promise 等待:LLM 通过 Promise 等待用户回答,实现异步交互
  3. 状态存储:pending 状态存储未回答的问题和回调
  4. 双向通信:通过 HTTP API 完成用户→服务端的响应
  5. 工具描述引导: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. 简洁的标签: 选项标签应该 1-5 个词
  2. 清晰的描述: 提供详细的说明
  3. 合理的默认值: 使用 custom: true 提供灵活性
  4. 分批问题: 避免一次问太多问题
  5. 关联工具: 在有意义的场景关联工具调用

对象关系

变更历史

版本变更内容日期
v1初始 Question 架构-
v1.1添加 custom 字段支持-

相关文档

  • Events - 事件系统
  • Message - 消息对象