Files
ycode/src/main/interaction-broker.ts
T

78 lines
4.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation'
import type { ChatEvent } from '@shared/types'
interface PendingInteraction { request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void }
const pending = new Map<string, PendingInteraction>()
function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) }
export function createInteractionBroker(runId: string, signal: AbortSignal, emit: (event: ChatEvent) => void) {
async function request(input: ChatInteraction): Promise<InteractionResponse> {
if (signal.aborted) throw abortError()
return new Promise((resolve, reject) => {
const onAbort = (): void => {
const entry = pending.get(input.id)
if (!entry) return
pending.delete(input.id)
entry.detach()
emit({ runId, type: 'interaction', interaction: { ...input, status: 'cancelled' } })
reject(abortError())
}
pending.set(input.id, { request: input, resolve, reject, detach: () => signal.removeEventListener('abort', onAbort) })
signal.addEventListener('abort', onAbort, { once: true })
emit({ runId, type: 'interaction', interaction: input })
})
}
return {
async question(toolCallId: string, title: string, questions: QuestionItem[]) {
const response = await request({ id: randomUUID(), runId, toolCallId, kind: 'question', title, questions, status: 'pending' })
if (response.kind !== 'question') throw new Error('问答响应类型不匹配')
return response
},
async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string) {
const id = randomUUID()
const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, status: 'pending' })
if (response.kind !== 'approval') throw new Error('审批响应类型不匹配')
return response
}
}
}
export type InteractionBroker = ReturnType<typeof createInteractionBroker>
/** Exact run/id binding makes stale or duplicated button submissions harmless. */
export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean {
const entry = pending.get(id)
if (!entry || entry.request.runId !== runId) return false
const request = entry.request
if (request.kind !== response.kind) throw new Error('交互响应类型不匹配')
let resolved: ChatInteraction
let finalResponse = response
if (request.kind === 'approval' && response.kind === 'approval') {
const option = request.options.find((item) => item.optionId === response.optionId)
if (!option) throw new Error('审批选项无效')
resolved = { ...request, selectedOptionId: option.optionId, status: option.kind.startsWith('reject') ? 'rejected' : 'answered' }
} else if (request.kind === 'question' && response.kind === 'question') {
if (!response.answer || !Array.isArray(response.answer.questions)) throw new Error('问答内容无效')
const ids = response.answer.questions.map((item) => item.id)
if (new Set(ids).size !== ids.length || ids.some((id) => !request.questions.some((question) => question.id === id))) throw new Error('问题标识无效')
if (!response.skipped) {
for (const question of request.questions) {
const answer = response.answer.questions.find((item) => item.id === question.id)
if (!answer || !Array.isArray(answer.answers) || answer.answers.some((value) => typeof value !== 'string' || !value.trim())) throw new Error('回答内容无效')
if (!question.multiSelect && answer.answers.length > 1) throw new Error('该问题仅支持单选')
}
if (!response.answer.questions.some((item) => item.answers.length)) throw new Error('请至少回答一个问题,或跳过全部')
}
const answer = { title: request.title, questions: request.questions.map((question) => ({ id: question.id, question: question.question,
answers: response.skipped ? [] : response.answer.questions.find((item) => item.id === question.id)?.answers ?? [] })) }
finalResponse = { ...response, answer }
resolved = { ...request, answer, skipped: response.skipped, status: 'answered' }
} else throw new Error('交互响应类型不匹配')
pending.delete(id)
entry.detach()
emit({ runId, type: 'interaction', interaction: resolved })
entry.resolve(finalResponse)
return true
}