78 lines
4.5 KiB
TypeScript
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
|
|
}
|