/** * API 通信模块 - HTTP 封装 + 错误拦截 + 参数映射 * * 核心参数映射(SDK → 后端): * integrateId → roleId(客服角色 ID) * userId → accountId(客户账号 ID) * chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成) */ import { ResolvedConfig, ApiResponse, CategoryNode } from './types'; import { logger } from './logger'; import { t } from './i18n'; /** 请求超时时间(毫秒) */ const REQUEST_TIMEOUT = 30000; let currentConfig: ResolvedConfig | null = null; /** 运行时动态设置的角色 ID(通过角色选择器切换),优先于 currentConfig.integrateId */ let activeIntegrateId: string | null = null; /** 设置当前配置 */ export function setApiConfig(config: ResolvedConfig): void { currentConfig = config; } /** 清空当前配置(销毁 SDK 时调用) */ export function clearApiConfig(): void { currentConfig = null; activeIntegrateId = null; } /** 设置当前活跃角色 ID(角色切换时调用),影响后续所有 API 调用的 roleId 参数 */ export function setActiveRoleId(roleId: string | null): void { activeIntegrateId = roleId; } /** 获取当前有效的 integrateId(优先使用运行时设置,回退到配置默认值) */ export function getActiveIntegrateId(): string { return activeIntegrateId ?? currentConfig?.integrateId ?? ''; } /** 更新当前 chatId(对话 ID) */ export function updateChatId(chatId: string): void { if (currentConfig) { currentConfig.chatId = chatId; } } /** 获取当前 chatId */ export function getChatId(): string { return currentConfig?.chatId || ''; } /** 构建完整请求 URL,自动防御双斜杠 */ function buildUrl(path: string): string { if (!currentConfig) { throw new Error('API 配置未初始化'); } const domain = currentConfig.requestDomain.replace(/\/+$/, ''); const cleanPath = path.startsWith('/') ? path : `/${path}`; return `${domain}${cleanPath}`; } /** * 安全设置可选参数:仅当 value 非空时追加 */ function setIfPresent(params: URLSearchParams, key: string, value: string | number | undefined): void { if (value === undefined || value === null) return; if (typeof value === 'string' && value.trim() === '') return; params.set(key, String(value)); } // ==================== 对话接口 URL 构建 ==================== /** * 构建同步对话请求 URL * - integrateId → roleId * - userId → accountId * - chatId → 自动管理的对话 ID */ function buildChatUrl(message: string): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); // integrateId 映射为 roleId setIfPresent(params, 'roleId', getActiveIntegrateId()); // userId 映射为 accountId setIfPresent(params, 'accountId', currentConfig!.userId); return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`); } /** * 构建 SSE 流式请求 URL */ function buildChatSSEUrl(message: string, categoryId?: number): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); setIfPresent(params, 'roleId', getActiveIntegrateId()); setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`); } /** * 构建 RAG 增强流式请求 URL */ function buildChatRAGSSEUrl(message: string, categoryId?: number): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); setIfPresent(params, 'roleId', getActiveIntegrateId()); setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`); } /** * 构建 RAG 引用来源请求 URL */ function buildRagSourcesUrl(message: string, categoryId?: number): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); setIfPresent(params, 'roleId', getActiveIntegrateId()); setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`); } // ==================== HTTP 基础封装 ==================== /** 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断) */ async function safeFetch( url: string, options: RequestInit = {}, timeout: number = REQUEST_TIMEOUT, externalSignal?: AbortSignal ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); // 外部信号触发时同步中断内部请求 if (externalSignal) { if (externalSignal.aborted) { controller.abort(); } else { externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); } } try { // 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口) const headers: Record = {}; if (options.headers) { if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { headers[key] = value; }); } else if (Array.isArray(options.headers)) { options.headers.forEach(([key, value]) => { headers[key] = value; }); } else { Object.assign(headers, options.headers as Record); } } if (currentConfig?.token && url.includes('/ai/')) { headers['Authorization'] = `Bearer ${currentConfig.token}`; } const response = await fetch(url, { ...options, headers, signal: controller.signal, mode: 'cors', credentials: 'include', }); if (response.status === 401 && url.includes('/ai/')) { logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token'); } return response; } catch (err: unknown) { // 用户主动中断(停止生成),不算错误 if (externalSignal?.aborted) { throw new CskError('aborted', 'aborted'); } if (err instanceof DOMException && err.name === 'AbortError') { throw new CskError(t('error_timeout'), 'timeout'); } if (err instanceof TypeError && err.message.includes('Failed to fetch')) { throw new CskError(t('error_cors'), 'cors'); } throw new CskError(t('error_network'), 'network'); } finally { clearTimeout(timer); } } /** 自定义错误类型 */ export class CskError extends Error { type: string; constructor(message: string, type: string) { super(message); this.name = 'CskError'; this.type = type; } } /** 根据 HTTP 状态码返回对应的国际化错误消息 */ function getHttpErrorMessage(status: number): string { switch (status) { case 401: return t('error_auth'); case 403: return t('error_forbidden'); case 404: return t('error_not_found'); case 429: return t('error_rate_limit'); case 500: return t('error_server'); case 502: case 503: return t('error_unavailable'); default: return `${t('error_unknown')}(${status})`; } } // ==================== 对话请求 ==================== /** * 同步对话请求 */ export async function chatRequest(message: string): Promise { const url = buildChatUrl(message); logger.lifecycleSend(getActiveIntegrateId(), message.length); try { const response = await safeFetch(url); if (!response.ok) { const errorMsg = getHttpErrorMessage(response.status); logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); throw new CskError(errorMsg, `http_${response.status}`); } const text = await response.text(); logger.lifecycleReply(getActiveIntegrateId(), text.length); return text; } catch (err) { if (err instanceof CskError) throw err; logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); throw new CskError(t('error_unknown'), 'unknown'); } } /** * SSE 流式对话请求 * @param useRag 是否使用 RAG 增强对话 * @param categoryId 知识库分类 ID */ export async function chatSSERequest( message: string, onChunk: (text: string) => void, onDone: () => void, onError: (error: CskError) => void, categoryId?: number, useRag?: boolean, signal?: AbortSignal ): Promise { const url = useRag ? buildChatRAGSSEUrl(message, categoryId) : buildChatSSEUrl(message, categoryId); let totalText = ''; logger.lifecycleSend(getActiveIntegrateId(), message.length); try { const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal); if (!response.ok) { const errorMsg = getHttpErrorMessage(response.status); logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); onError(new CskError(errorMsg, `http_${response.status}`)); return; } const reader = response.body?.getReader(); if (!reader) { onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported')); return; } const decoder = new TextDecoder('utf-8', { stream: true } as TextDecoderOptions); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith(':')) continue; if (trimmed.startsWith('data:')) { const data = trimmed.substring(5).trim(); if (data) { totalText += data; onChunk(data); } } else if (trimmed === '[DONE]') { break; } else if (!trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) { totalText += trimmed; onChunk(trimmed); } } } if (buffer.trim()) { const trimmed = buffer.trim(); if (trimmed.startsWith('data:')) { const data = trimmed.substring(5).trim(); if (data) { totalText += data; onChunk(data); } } else if (trimmed !== '[DONE]') { totalText += trimmed; onChunk(trimmed); } } } catch (readErr: unknown) { // 用户主动中断:视为正常结束,保留已生成内容 if (signal?.aborted) { logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`); } else if (totalText.length > 0) { onChunk('\n\n' + t('stream_unstable')); } else { throw readErr; } } finally { reader.releaseLock(); } logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length); onDone(); } catch (err) { // 用户主动中断不触发 onError,走 onDone if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) { onDone(); return; } if (err instanceof CskError) { onError(err); } else { logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); onError(new CskError(t('error_network'), 'network')); } } } // ==================== P1: 知识库分类 ==================== /** * 获取知识库分类树 */ export async function fetchCategoryTree(): Promise { const url = buildUrl('/category/tree'); try { const response = await safeFetch(url); if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); const json: ApiResponse = await response.json(); if (json.success && Array.isArray(json.data)) { logger.info(`加载分类树成功 count=${json.data.length}`); return json.data; } return []; } catch (err) { if (err instanceof CskError) logger.error(`加载分类树失败: ${err.message}`); else logger.error('加载分类树失败', err); return []; } } // ==================== P1: RAG 引用来源 ==================== /** RAG 来源响应数据项 */ interface RagSourceData { documentId?: string; title?: string; sourceName?: string; chunkIndex?: number; score?: number; snippet?: string; } /** * 获取 RAG 引用来源 */ export async function fetchRagSources(message: string, categoryId?: number): Promise { const url = buildRagSourcesUrl(message, categoryId); try { const response = await safeFetch(url); if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); const json: ApiResponse = await response.json(); if (json.success && Array.isArray(json.data)) { logger.info(`获取引用来源 count=${json.data.length}`); return json.data; } return []; } catch (err) { logger.error('获取引用来源失败', err); return []; } } // ==================== P0-002: 消息反馈 ==================== /** * 提交消息反馈(点赞/点踩) */ export async function submitFeedbackApi( messageId: string, feedbackType: 'THUMBS_UP' | 'THUMBS_DOWN' ): Promise { if (!currentConfig) return false; const url = buildUrl('/feedback'); try { const response = await safeFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId: String(messageId), conversationId: currentConfig.chatId, feedbackType, }), }); if (!response.ok) { logger.error(`反馈提交失败 status=${response.status}`); return false; } const json: ApiResponse = await response.json(); return json.success || false; } catch (err) { logger.error('反馈提交异常', err); return false; } } // ==================== P2: 会话管理 + chatId 初始化 ==================== /** 会话列表项 */ interface ConversationItem { conversationId?: string; chatId?: string; accountId?: string; roleId?: number; roleName?: string; messageCount?: number; lastMessageTime?: string; firstMessageTime?: string; lastMessagePreview?: string; createdAt?: string; } /** * 获取会话列表(SDK 安全端点) * 使用 /ai/sdk/conversation/list,强制按 accountId + roleId 过滤 */ export async function fetchConversationList( page: number = 1, size: number = 20, accountId?: string, roleId?: string ): Promise<{ list: ConversationItem[]; total: number; pages: number; }> { const params = new URLSearchParams(); params.set('page', String(page)); params.set('size', String(size)); if (accountId) params.set('accountId', accountId); if (roleId) params.set('roleId', roleId); const url = buildUrl(`/ai/sdk/conversation/list?${params.toString()}`); try { const response = await safeFetch(url); if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); const json: ApiResponse = await response.json(); return { list: json.success && Array.isArray(json.data) ? json.data : [], total: json.total || 0, pages: json.pages || 0, }; } catch (err) { logger.error('加载会话列表失败', err); return { list: [], total: 0, pages: 0 }; } } /** * 获取会话消息(SDK 安全端点) * 使用 /ai/sdk/conversation/{id}/messages,含账户归属校验 */ export async function fetchConversationMessages(conversationId: string): Promise<{ messages: Array<{ messageType: string; content: string; createTime: string }>; total: number; }> { const params = new URLSearchParams(); if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId()); const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`); try { const response = await safeFetch(url); if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); const json = await response.json(); return { messages: json.success && Array.isArray(json.data) ? json.data : [], total: json.total || 0, }; } catch (err) { logger.error('加载会话消息失败', err); return { messages: [], total: 0 }; } } /** * 删除会话(SDK 安全端点) * 使用 DELETE /ai/sdk/conversation/{id},含账户归属校验 */ export async function deleteConversation(conversationId: string): Promise { const params = new URLSearchParams(); if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId()); const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`); try { const response = await safeFetch(url, { method: 'DELETE' }); if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); const json: ApiResponse = await response.json(); logger.info(`删除会话 id=${conversationId} success=${json.success}`); return json.success || false; } catch (err) { logger.error('删除会话失败', err); return false; } } /** * 导出会话 URL(SDK 安全端点) * 使用 /ai/sdk/conversation/{id}/export,含账户归属参数 */ export function getConversationExportUrl(conversationId: string): string { const params = new URLSearchParams(); if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId()); return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); } // ==================== chatId 自动初始化 ==================== /** * 初始化 chatId:查询后端已有会话,找到则复用,否则生成新的 * * @param forceRefresh 为 true 时跳过 localStorage 缓存,强制查询后端 */ export async function initChatId(forceRefresh: boolean = false): Promise { if (!currentConfig) return ''; const activeId = getActiveIntegrateId(); // 1. 先从 localStorage 恢复(非强制刷新时) if (!forceRefresh) { const cachedChatId = loadCachedChatId(activeId, currentConfig.userId); if (cachedChatId) { currentConfig.chatId = cachedChatId; logger.info(`从缓存恢复 chatId=${cachedChatId}`); return cachedChatId; } } // 2. 查询后端会话列表 try { const result = await fetchConversationList(1, 5, currentConfig.userId, activeId); if (result.list.length > 0) { // 使用最新会话的 conversationId 作为 chatId const latestConv = result.list[0]; const chatId = latestConv.conversationId || latestConv.chatId || ''; if (chatId) { currentConfig.chatId = chatId; saveCachedChatId(activeId, currentConfig.userId, chatId); logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`); return chatId; } } } catch (err) { logger.warn('查询后端会话列表失败,将生成新 chatId', err); } // 3. 生成新的 chatId const newChatId = generateChatId(); currentConfig.chatId = newChatId; saveCachedChatId(activeId, currentConfig.userId, newChatId); logger.info(`生成新 chatId=${newChatId}`); return newChatId; } /** 生成 chatId(格式:sdk_timestamp_random) */ function generateChatId(): string { const random = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().substring(0, 8) : Math.random().toString(36).substring(2, 10); return `sdk_${Date.now()}_${random}`; } /** localStorage key 格式 */ function chatIdStorageKey(integrateId: string, userId?: string): string { return `csk_chatId_${integrateId}${userId ? '_' + userId : ''}`; } /** 从 localStorage 加载 chatId */ function loadCachedChatId(integrateId: string, userId?: string): string { try { return localStorage.getItem(chatIdStorageKey(integrateId, userId)) || ''; } catch { return ''; } } /** 保存 chatId 到 localStorage */ export function saveCachedChatId(integrateId: string, userId?: string, chatId?: string): void { try { if (chatId) { localStorage.setItem(chatIdStorageKey(integrateId, userId), chatId); } else { localStorage.removeItem(chatIdStorageKey(integrateId, userId)); } } catch { // localStorage 不可用则忽略 } }