import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { convertToModelMessages, RetryError, safeValidateUIMessages, stepCountIs, streamText, type FinishReason, type StreamTextTransform, type TextStreamPart, type Tool, type ToolSet, type UIMessage, } from 'ai'; import { getAiSemaphore } from '../ai-shared/AiConcurrency'; import { AiApiError, AiErrorCodes } from '../ai-shared/AiError'; import { AI_LIMITS, utf8ByteLength } from '../ai-shared/AiLimits'; import { readAiApiKey, resolveAiRuntimeConfig, type AiRuntimeConfig, } from '../ai-shared/AiRuntimeConfig'; import { createAiTimeoutBudget } from '../ai-shared/AiTimeout'; import { scrubClientPrivilegeMessages, toPlainTextHistory } from './AiChatMessageHistory'; import { normalizeAiChatPageContext, type AiChatPageContext } from './AiChatPageContext'; import { KnowledgeRepository } from './KnowledgeRepository'; import { composeAssistantSystemPrompt } from './toolkit/AssistantPromptComposer'; import { assembleAssistantTools, type AssistantToolSurface, } from './toolkit/AssistantToolRegistry'; import type { AssistantToolDisplay } from './toolkit/AssistantToolkit'; import { createAssistantToolkits } from './toolkit/AssistantToolkits'; // 工具面由注册中心动态组装,消息类型不再绑死某一组工具; // 历史经 toPlainTextHistory 已剥成纯文本,泛化不影响任何既有会话。 type AiAssistantMessage = UIMessage; export interface AiChatRequest { id: string; messages: AiAssistantMessage[]; trigger: string; /** 浏览器侧上报的当前页面上下文(可选);缺省没有,模型就只知道页面地址。 */ pageContext?: AiChatPageContext; } export class AiAssistantRuntime { private static instance: AiAssistantRuntime | undefined; static getInstance(): AiAssistantRuntime { return this.instance ??= new AiAssistantRuntime(); } async validateRequest(body: unknown): Promise { if (!isRecord(body)) { throw invalidChat('请求体必须是对象'); } if (typeof body.id !== 'string' || !body.id.trim()) { throw invalidChat('id 必须是非空字符串'); } if (typeof body.trigger !== 'string' || !body.trigger.trim()) { throw invalidChat('trigger 必须是非空字符串'); } if (!Array.isArray(body.messages) || body.messages.length > AI_LIMITS.chatMessages) { throw invalidChat(`messages 必须是数组且不超过 ${AI_LIMITS.chatMessages} 条`); } // 先剥离客户端历史里的系统权限污染,再做结构校验;避免旧会话脏数据直接 4513 拒答。 const scrubbedMessages = scrubClientPrivilegeMessages(body.messages); if (scrubbedMessages.length === 0) { throw invalidChat('messages 不能为空'); } for (const rawMessage of scrubbedMessages) { validateMessageAuthority(rawMessage); if (utf8ByteLength(JSON.stringify(rawMessage)) > AI_LIMITS.chatMessageBytes) { throw invalidChat('单条消息超过 32KB'); } } const validated = await safeValidateUIMessages({ messages: scrubbedMessages }); if (!validated.success) { throw invalidChat('messages 结构不合法'); } // 页面上下文是锦上添花:字段坏了/超帽了就当没有,不因此拒掉整条对话。 const pageContext = normalizeAiChatPageContext(body.pageContext); return { id: body.id.trim(), messages: toPlainTextHistory(validated.data), trigger: body.trigger.trim(), ...(pageContext ? { pageContext } : {}), }; } /** * 组装本次请求的工具面。每请求组装、无跨请求可变态,与 KnowledgeRepository 同纪律。 */ async resolveToolSurface( config: AiRuntimeConfig, requestOrigin: string, signal?: AbortSignal, ): Promise { return assembleAssistantTools(createAssistantToolkits(), { config, hostDir: config.hostDir, requestOrigin, signal, }); } /** tools-meta 接口用:只回展示文案,不回参数结构与实现路径。 */ async listToolDisplays( requestOrigin: string, signal?: AbortSignal, ): Promise<{ tools: Record }> { const config = resolveAiRuntimeConfig(); const surface = await this.resolveToolSurface(config, requestOrigin, signal); return { tools: surface.display }; } async createChatResponse( request: AiChatRequest, parentSignal?: AbortSignal, requestOrigin = '', ): Promise { const config = resolveAiRuntimeConfig(); const budget = createAiTimeoutBudget(config.timeoutMs, parentSignal); const semaphore = getAiSemaphore(config.maxConcurrency); let release: (() => void) | undefined; let cleanedUp = false; const cleanup = () => { if (cleanedUp) { return; } cleanedUp = true; release?.(); budget.dispose(); }; try { const surface = await this.resolveToolSurface(config, requestOrigin, budget.signal); const tools = surface.tools; const validated = await safeValidateUIMessages({ messages: request.messages, // SDK 的 tools 形参要求由静态工具类型推导出的形状,而本体系的工具面是运行期 // 组装的;两者结构一致,只是类型层推不出来。 tools: tools as unknown as Record>, }); if (!validated.success) { throw invalidChat('assistant 工具历史结构不合法'); } const provider = createOpenAICompatible({ apiKey: readAiApiKey(config), baseURL: config.baseUrl, name: 'openai-compatible', supportsStructuredOutputs: false, includeUsage: true, }); release = await semaphore.acquire(budget.signal); const modelMessages = convertToModelMessages(validated.data, { tools }); const maxSteps = config.assistant.maxSteps; const result = streamText({ model: provider(config.assistant.model ?? config.model), system: composeAssistantSystemPrompt( config.assistant.systemPrompt, surface.capabilities, request.pageContext, ), messages: modelMessages, allowSystemInMessages: false, tools, maxRetries: 2, abortSignal: budget.signal, stopWhen: stepCountIs(maxSteps + 1), experimental_transform: createAssistantCompletionGuard(budget.signal), prepareStep: ({ stepNumber }) => ( stepNumber >= maxSteps ? { toolChoice: 'none', activeTools: [] } : undefined ), onFinish: cleanup, onAbort: cleanup, onError: cleanup, }); const response = result.toUIMessageStreamResponse({ onError: (error) => mapStreamError(error), }); const headers = new Headers(response.headers); headers.set('Cache-Control', 'no-cache, no-transform'); headers.set('Connection', 'keep-alive'); headers.set('X-Accel-Buffering', 'no'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } catch (error) { cleanup(); throw error; } } listKnowledge(dir?: string, signal?: AbortSignal): { roots: string[]; files: ReturnType['files']; } { const config = resolveAiRuntimeConfig(); const repository = new KnowledgeRepository(config); return { roots: repository.rootNames(), files: repository.listFiles(dir, signal).files, }; } } function validateMessageAuthority(value: unknown): void { if (!isRecord(value)) { throw invalidChat('消息必须是对象'); } if (typeof value.id !== 'string' || !value.id.trim()) { throw invalidChat('消息 id 必须是非空字符串'); } if (value.role !== 'user' && value.role !== 'assistant') { throw invalidChat('消息角色仅允许 user 或 assistant'); } if (!Array.isArray(value.parts) || value.parts.length === 0) { throw invalidChat('消息 parts 不能为空'); } if (value.role === 'user') { for (const part of value.parts) { if (!isRecord(part) || part.type !== 'text' || typeof part.text !== 'string') { throw invalidChat('用户消息仅允许 text part'); } } } for (const part of value.parts) { if (!isRecord(part) || typeof part.type !== 'string') { throw invalidChat('消息 part 结构不合法'); } if (isPrivilegePart(part)) { throw invalidChat('客户端不得提供系统权限内容'); } } } function isPrivilegePart(part: Record): boolean { return part.type === 'system' || part.type === 'data-system' || ('role' in part && part.role === 'system'); } export { scrubClientPrivilegeMessages, toPlainTextHistory } from './AiChatMessageHistory'; export function createAssistantCompletionGuard( signal: AbortSignal, ): StreamTextTransform { return () => new TransformStream, TextStreamPart>({ transform(part, controller) { if (part.type === 'abort') { controller.enqueue(isTimeoutSignal(signal) ? { type: 'error', error: new AiApiError(AiErrorCodes.AI_UPSTREAM_TIMEOUT, 'AI 服务调用超时'), } : part); return; } if (part.type === 'error') { if (isIntentionalAbort(signal)) { controller.enqueue({ type: 'abort' }); return; } controller.enqueue({ type: 'error', error: normalizeStreamFailure(part.error, signal) }); return; } if (part.type === 'finish' && !isCompleteFinishReason(part.finishReason)) { controller.enqueue({ type: 'error', error: new AiApiError( AiErrorCodes.AI_UPSTREAM_ERROR, incompleteCompletionMessage(part.finishReason), ), }); } controller.enqueue(part); }, }); } export function mapStreamError(error: unknown): string { const upstream = RetryError.isInstance(error) ? error.lastError : error; if (upstream instanceof AiApiError) { return upstream.message; } if (isTimeoutError(upstream)) { return 'AI 服务调用超时'; } const status = isRecord(upstream) && typeof upstream.statusCode === 'number' ? upstream.statusCode : isRecord(upstream) && typeof upstream.status === 'number' ? upstream.status : undefined; if (status === 429) { return 'AI 服务繁忙,请稍后重试'; } if (status === 401 || status === 403) { return 'AI 服务未配置'; } return 'AI 上游服务异常'; } function isCompleteFinishReason(finishReason: FinishReason): boolean { return finishReason === 'stop'; } function incompleteCompletionMessage(finishReason: string): string { if (finishReason === 'content-filter') { return 'AI 回答因内容安全限制未完整生成'; } return 'AI 回答未完整生成'; } function normalizeStreamFailure(error: unknown, signal: AbortSignal): unknown { if (isTimeoutSignal(signal) || isTimeoutError(error)) { return new AiApiError(AiErrorCodes.AI_UPSTREAM_TIMEOUT, 'AI 服务调用超时'); } return error; } function isIntentionalAbort(signal: AbortSignal): boolean { return signal.aborted && !isTimeoutSignal(signal); } function isTimeoutSignal(signal: AbortSignal): boolean { return signal.aborted && isTimeoutError(signal.reason); } function isTimeoutError(error: unknown): boolean { return error instanceof Error && error.name === 'TimeoutError'; } function invalidChat(message: string): AiApiError { return new AiApiError(AiErrorCodes.AI_CHAT_INPUT_INVALID, message); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }