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() 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 { 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 /** 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 }