import '../../../client/configure-client-runtime'; import { DefaultChatTransport, type ChatStatus, type UIMessage, } from 'ai'; import { toPlainTextHistory } from '../AiChatMessageHistory'; import { normalizeAiChatPageContext, type AiChatPageContext } from '../AiChatPageContext'; import { FrameworkAgnosticChat } from './FrameworkAgnosticChat'; export interface AiChatViewState { messages: UIMessage[]; status: ChatStatus; error?: string; } export interface AiChatControllerOptions { endpoint?: string; pageHref: () => string; /** 当前页面上下文(可操作控件清单 + 最近几步操作结果);宿主不接就不带。 */ pageContext?: () => AiChatPageContext | null | undefined; onChange: (state: AiChatViewState) => void; } export class AiChatController { private readonly chat: FrameworkAgnosticChat; private readonly unsubscribe: () => void; private readonly onChange: AiChatControllerOptions['onChange']; constructor(options: AiChatControllerOptions) { this.onChange = options.onChange; this.chat = new FrameworkAgnosticChat({ transport: new DefaultChatTransport({ api: options.endpoint ?? '/api/ai-assistant/chat', credentials: 'include', fetch: chatFetch, headers: () => ({ 'x-admin-page-href': normalizePageHref(options.pageHref()), }), prepareSendMessagesRequest: ({ id, messages, body, headers, credentials, api, trigger }) => ({ api, headers, credentials, body: { ...body, id, // 与服务端 toPlainTextHistory 对齐:不上传 tool/system 等 UI 结构,避免 4513。 messages: toPlainTextHistory(messages as never), trigger, // 每次现取:页面随时在变,缓存下来的清单只会误导模型。 ...readPageContext(options.pageContext), }, }), }), onError: () => { this.recoverInvalidSession(); this.emit(); }, onFinish: () => this.emit(), }); this.unsubscribe = this.chat.subscribe(() => this.emit()); this.emit(); } async send(text: string): Promise { const normalized = text.trim(); if (!normalized || this.isBusy()) { return; } if (this.chat.status === 'error') { this.chat.clearError(); } await this.chat.sendMessage({ text: normalized }); this.recoverInvalidSession(); } async retry(): Promise { if (this.isBusy() || this.chat.messages.length === 0) { return; } if (this.chat.status === 'error') { this.chat.clearError(); } await this.chat.regenerate(); this.recoverInvalidSession(); } async stop(): Promise { await this.chat.stop(); } clear(): void { void this.chat.stop(); this.chat.messages = []; if (this.chat.status === 'error') { this.chat.clearError(); } } restore(messages: UIMessage[]): void { if (this.isBusy()) { return; } this.chat.messages = structuredClone(toPlainTextHistory(messages as never)) as UIMessage[]; } /** * 落盘/回传用的历史:剥成纯文本。 * 与视图状态刻意分开——本地存档不该留工具结果(体积与隐私),回传给服务端的历史 * 也必须是纯文本(服务端 toPlainTextHistory 同守这条)。 */ snapshot(): UIMessage[] { return structuredClone(toPlainTextHistory(this.chat.messages as never)) as UIMessage[]; } destroy(): void { this.unsubscribe(); void this.chat.stop(); } private recoverInvalidSession(): void { const error = this.chat.error; if (!error || !isInvalidChatInputError(error)) { return; } this.chat.messages = []; } private isBusy(): boolean { return this.chat.status === 'submitted' || this.chat.status === 'streaming'; } private emit(): void { this.onChange({ // 视图要的是**完整** parts:工具调用状态(tool-)全靠它渲染。 // 这里曾经发的是 snapshot(),纯文本历史里没有工具 part,挂件的工具三态因此 // 一直是死代码——渲染不出来,也就没人发现它渲染不出来。 messages: this.chat.messages as UIMessage[], status: this.chat.status, ...(this.chat.error ? { error: toUserMessage(this.chat.error) } : {}), }); } } function normalizePageHref(value: string): string { const normalized = value.trim(); return normalized.startsWith('/') && !normalized.startsWith('//') ? normalized : '/'; } /** * 宿主给的取值函数不许拖垮发消息:抛了就当没有上下文照发, * 顺手过一遍两端共用的规整(字节帽/条数帽在这里就落地,不等服务端才砍)。 */ function readPageContext( provider: AiChatControllerOptions['pageContext'], ): { pageContext?: AiChatPageContext } { if (!provider) { return {}; } try { const normalized = normalizeAiChatPageContext(provider()); return normalized ? { pageContext: normalized } : {}; } catch { return {}; } } function toUserMessage(error: Error): string { const message = error.message; if (/4513|CHAT_INPUT_INVALID|客户端不得提供系统权限|会话内容不合法/i.test(message)) { return '会话内容不合法,已清空,请重试'; } if (/4507|429|rate.?limit/i.test(message)) { return 'AI 服务繁忙,请稍后重试'; } if (/4506|timeout|abort/i.test(message)) { return 'AI 响应超时,请重试'; } if (/401|unauthorized|未登录/i.test(message)) { return '登录状态已失效,请重新登录'; } return 'AI 助手暂时不可用,请稍后重试'; } function isInvalidChatInputError(error: Error): boolean { return /4513|CHAT_INPUT_INVALID|客户端不得提供系统权限/i.test(error.message); } /** * AI SDK 仅在 !response.ok 时抛错;业务错误若被包装成 HTTP 200 + JSON,会被当成成功流导致“没反应”。 */ async function chatFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const response = await fetch(input, init); const contentType = response.headers.get('content-type') ?? ''; if (contentType.includes('text/event-stream')) { return response; } const text = await response.text(); const payload = tryParseJson(text); if (isAiErrorEnvelope(payload)) { const code = payload.code; const message = payload.message || payload.msg || text; throw new Error(`${code}:${message}`); } if (!response.ok) { throw new Error(text || `HTTP ${response.status}`); } throw new Error(text || 'AI 助手响应异常'); } function tryParseJson(text: string): unknown { try { return JSON.parse(text) as unknown; } catch { return undefined; } } function isAiErrorEnvelope(value: unknown): value is { code: number; success?: boolean; message?: string; msg?: string; } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as { code?: unknown }).code === 'number' && (value as { success?: unknown }).success === false; }