import type { AskResponse, EmotionMode, Expert, ExpertService, Consultation, FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc, Scene, SessionMode, SessionResponse, Settings, } from '@/types' const BASE = '/api' /** 请求超时时间(ms),弱网时自动延长 */ const DEFAULT_TIMEOUT = 30000 const LONG_TIMEOUT = 60000 // ask 等慢接口 /** 网络状态检测 */ export const network = { isOnline: () => typeof navigator !== 'undefined' ? navigator.onLine : true, onChange: (cb: (online: boolean) => void) => { if (typeof window === 'undefined') return () => {} const handler = () => cb(navigator.onLine) window.addEventListener('online', handler) window.addEventListener('offline', handler) return () => { window.removeEventListener('online', handler) window.removeEventListener('offline', handler) } }, } /** 带 Authorization header 的请求 */ function authHeaders(): Record { const token = typeof window !== 'undefined' ? localStorage.getItem('bossai_token') : null return token ? { Authorization: `Bearer ${token}` } : {} } /** 带超时的 fetch */ async function fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeout) try { return await fetch(url, { ...init, signal: controller.signal }) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('请求超时,请检查网络后重试') } if (!navigator.onLine) { throw new Error('网络已断开,请检查网络连接') } throw err } finally { clearTimeout(timer) } } /** 带重试的请求(网络错误/超时重试,429 限流不重试) */ async function requestWithRetry( path: string, init?: RequestInit, options?: { timeout?: number; retries?: number }, ): Promise { const timeout = options?.timeout ?? DEFAULT_TIMEOUT const maxRetries = options?.retries ?? 1 let lastError: Error for (let attempt = 0; attempt <= maxRetries; attempt++) { if (!navigator.onLine) { throw new Error('网络已断开,请检查网络连接') } try { const isFormData = init?.body instanceof FormData const res = await fetchWithTimeout(BASE + path, { ...init, headers: { ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...authHeaders(), ...(init?.headers || {}), }, }, timeout) if (res.status === 429) { const retryAfter = res.headers.get('Retry-After') const wait = retryAfter ? parseInt(retryAfter, 10) : 2 throw new Error(`请求过于频繁,请 ${wait} 秒后重试`) } if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })) // FastAPI HTTPException 返回 {detail: "字符串"},422 校验错误返回 {detail: [{msg: "..."}]} let msg: string if (typeof body.detail === 'string') { msg = body.detail } else if (Array.isArray(body.detail) && body.detail.length > 0) { msg = body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join('; ') } else { msg = `请求失败 ${res.status}` } throw new Error(msg) } return res.json() as Promise } catch (err) { lastError = err as Error // 超时或网络错误才重试,业务错误不重试 const isRetryable = lastError.message.includes('超时') || lastError.message.includes('网络') || lastError.message.includes('Failed to fetch') if (!isRetryable || attempt === maxRetries) { throw lastError } // 指数退避:1s, 2s await new Promise(r => setTimeout(r, 1000 * (attempt + 1))) } } throw lastError! } /** 兼容旧接口 */ async function request(path: string, init?: RequestInit): Promise { return requestWithRetry(path, init) } /** API 客户端:所有模型调用经后端完成,前端不持有密钥 */ export const api = { health: () => request<{ status: string; service: string }>('/health'), /** AI 生成示例问题(换一批) */ generateSamples: ( scene: Scene = 'legal', topic?: string, existing?: string[], ) => request<{ samples: string[] }>('/samples', { method: 'POST', body: JSON.stringify({ scene, topic, existing }), }), createSession: (mode: SessionMode) => request('/sessions', { method: 'POST', body: JSON.stringify({ mode }), }), ask: (sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode) => requestWithRetry(`/sessions/${sid}/ask`, { method: 'POST', body: JSON.stringify({ question, scene, emotion_mode: emotionMode }), }, { timeout: LONG_TIMEOUT, retries: 0 }), // ask 不重试(可能产生重复调用) /** 注册 */ register: (phone: string, password: string, name: string = '') => request<{ phone: string; name: string; token: string }>('/auth/register', { method: 'POST', body: JSON.stringify({ phone, password, name }), }), /** 登录 */ login: (phone: string, password: string) => request<{ phone: string; name: string; token: string; role?: string; expert_id?: number; admin_id?: number }>('/auth/login', { method: 'POST', body: JSON.stringify({ phone, password }), }), /** 获取用户画像 */ getProfile: () => requestWithRetry>('/profile/me', {}), /** 更新用户画像(确认事实后调用) */ updateProfile: (data: { industry?: string company_size?: string role?: string risk_tolerance?: string decision_style?: string key_facts?: Record }) => request<{ updated: boolean }>('/profile/me', { method: 'PUT', body: JSON.stringify(data), }), /** 获取用户画像历史版本 */ getProfileHistory: () => request<{ results: Array<{ id: number; version: number; profile: Record; source: string; created_at: string }> }>('/profile/me/history'), /** 事实追问分析:返回追问问题或标记事实已充分 */ askAnalyze: ( sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode, factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>, ) => request<{ need_ask: boolean need_analysis: boolean round: number questions?: Array<{ dim_key: string; dim_desc: string; question: string }> fact_summary?: string question_type: string risk_level: string profile_facts?: Array<{ dim_key: string; dim_desc: string; answer: string }> }>(`/sessions/${sid}/ask-analyze`, { method: 'POST', body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers, }), }), /** 流式提问:SSE 接收风险卡 + 逐 token 答复 * 回调: * - onRisk: 收到风险卡 * - onDelta: 收到 token 片段 * - onDone: 流式完成,返回完整响应 * - onError: 错误 */ askStream: async ( sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode, callbacks?: { onRisk?: (risk: AskResponse['risk']) => void onDelta?: (content: string) => void onDone?: (data: Partial) => void onError?: (error: string) => void }, factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>, ) => { if (!navigator.onLine) { callbacks?.onError?.('网络已断开,请检查网络连接') return } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), LONG_TIMEOUT) try { const res = await fetch(`${BASE}/sessions/${sid}/ask-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers }), signal: controller.signal, }) if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })) callbacks?.onError?.(detail.detail || `请求失败 ${res.status}`) return } const reader = res.body?.getReader() if (!reader) { callbacks?.onError?.('浏览器不支持流式读取') return } const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // SSE 事件以 \n\n 分隔 const parts = buffer.split('\n\n') buffer = parts.pop() || '' for (const part of parts) { if (!part.trim()) continue let eventType = 'message' let dataLine = '' for (const line of part.split('\n')) { if (line.startsWith('event: ')) eventType = line.slice(7) else if (line.startsWith('data: ')) dataLine = line.slice(6) } if (!dataLine) continue try { const data = JSON.parse(dataLine) if (eventType === 'risk') callbacks?.onRisk?.(data) else if (eventType === 'delta') callbacks?.onDelta?.(data.content || '') else if (eventType === 'done') callbacks?.onDone?.(data) else if (eventType === 'error') callbacks?.onError?.(data.detail || '未知错误') } catch { // 忽略解析失败的事件 } } } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { callbacks?.onError?.('请求超时,请检查网络后重试') } else { callbacks?.onError?.(err instanceof Error ? err.message : '流式请求失败') } } finally { clearTimeout(timer) } }, destroy: (sid: string) => request<{ destroyed: boolean; session_id: string }>( `/sessions/${sid}/destroy`, { method: 'POST' }, ), uploadFile: async (sid: string, file: File) => { const form = new FormData() form.append('file', file) const res = await fetch(`${BASE}/sessions/${sid}/file`, { method: 'POST', body: form, }) if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(detail.detail || `上传失败 ${res.status}`) } return res.json() as Promise }, addKnowledge: (data: { title: string content: string source?: string doc_type?: string region?: string | null effective_date?: string | null keywords?: string | null }) => request('/knowledge', { method: 'POST', body: JSON.stringify(data), }), listKnowledge: (docType?: string) => request<{ results: KnowledgeDoc[] }>( `/knowledge${docType ? `?doc_type=${docType}` : ''}`, ), searchKnowledge: (q: string, docType?: string) => request<{ results: KnowledgeDoc[] }>( `/knowledge/search?q=${encodeURIComponent(q)}${docType ? `&doc_type=${docType}` : ''}`, ), deleteKnowledge: (id: number) => request<{ deleted: boolean; id: number }>(`/knowledge/${id}`, { method: 'DELETE', }), // ===== 用户设置 ===== getSettings: () => request<{ settings: Settings }>('/settings'), updateSettings: (settings: Settings) => request<{ updated: boolean; settings: Settings }>('/settings', { method: 'PUT', body: JSON.stringify({ settings }), }), // ===== 专家库(用户端) ===== listExperts: (category?: string) => request<{ results: Expert[] }>(`/experts${category ? `?category=${category}` : ''}`), getExpert: (id: number) => request(`/experts/${id}`), selectExpert: (expertId: number) => request<{ service_id: number; status: string }>(`/experts/${expertId}/select`, { method: 'POST', }), myServices: () => request<{ results: ExpertService[] }>('/my/services'), myConsultations: (status?: string) => request<{ results: Consultation[] }>(`/my/consultations${status ? `?status=${status}` : ''}`), createConsultation: (data: { expert_id: number; topic: string; context?: string; question?: string }) => request<{ consultation_id: number; created_at: string; status: string }>('/consultations', { method: 'POST', body: JSON.stringify(data), }), rateConsultation: (consultationId: number, rating: number, comment: string = '') => request<{ rating_id: number; created_at: string }>(`/consultations/${consultationId}/rate`, { method: 'POST', body: JSON.stringify({ consultation_id: consultationId, rating, comment }), }), // ===== 专家工作台 ===== expertWorkbench: () => request<{ expert: Expert; services: ExpertService[]; consultations: Consultation[] }>('/expert/workbench'), acceptService: (serviceId: number) => request<{ accepted: boolean }>(`/expert/services/${serviceId}/accept`, { method: 'POST', }), /** 用户取消服务关系 */ cancelMyService: (serviceId: number) => request<{ cancelled: boolean }>(`/my/services/${serviceId}/cancel`, { method: 'POST', }), /** 专家取消服务关系 */ cancelExpertService: (serviceId: number) => request<{ cancelled: boolean }>(`/expert/services/${serviceId}/cancel`, { method: 'POST', }), replyConsultation: (consultationId: number, reply: string) => request<{ replied: boolean }>(`/expert/consultations/${consultationId}/reply`, { method: 'POST', body: JSON.stringify({ reply }), }), // ===== 管理后台 ===== adminListExperts: (status?: string) => request<{ results: Expert[] }>(`/admin/experts${status ? `?status=${status}` : ''}`), adminUpdateExpertStatus: (expertId: number, status: 'active' | 'inactive') => request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, { method: 'PATCH', body: JSON.stringify({ status }), }), /** 管理端:编辑专家信息 */ adminUpdateExpert: (expertId: number, data: Partial>) => request<{ updated: boolean; expert: Expert }>(`/admin/experts/${expertId}`, { method: 'PATCH', body: JSON.stringify(data), }), /** 管理端:上传专家头像(返回 avatar_url) */ adminUploadExpertAvatar: (expertId: number, file: File) => { const formData = new FormData() formData.append('file', file) return requestWithRetry<{ avatar_url: string }>(`/admin/experts/${expertId}/avatar`, { method: 'POST', body: formData, // FormData 不能设 Content-Type,浏览器自动设 multipart headers: {}, }, { timeout: 30000, retries: 0 }) }, adminListUsers: (q?: string) => request<{ results: Array<{ id: number; phone: string; name: string; status: string; created_at: string; message_count: number; last_active: string | null; last_scene: string | null }> }>( `/admin/users${q ? `?q=${encodeURIComponent(q)}` : ''}`, ), /** 管理端:重置用户密码 */ adminResetPassword: (userId: number, password: string) => request<{ reset: boolean }>(`/admin/users/${userId}/reset-password`, { method: 'POST', body: JSON.stringify({ password }), }), /** 管理端:激活/停用用户 */ adminUpdateUserStatus: (userId: number, status: 'active' | 'inactive') => request<{ status: string }>(`/admin/users/${userId}/status`, { method: 'POST', body: JSON.stringify({ status }), }), adminStats: () => request<{ users: number experts_active: number experts_total: number services_active: number consultations_total: number consultations_pending: number }>('/admin/stats'), /** 管理端:AI 使用统计(不含具体内容) */ adminAiUsage: () => request<{ total_questions: number active_users: number by_scene: Array<{ scene: string; count: number }> by_mode: Array<{ mode: string; count: number }> by_risk: Array<{ level: string; count: number }> by_inference: Array<{ location: string; count: number }> daily_trend: Array<{ date: string; count: number }> by_user: Array<{ phone_masked: string; questions: number; replies: number; last_active: string | null; top_scene: string | null }> }>('/admin/ai-usage'), /** 管理端:专家沟通统计(不含具体内容) */ adminExpertActivity: () => request<{ total: number replied: number pending: number by_status: Array<{ status: string; count: number }> by_category: Array<{ category: string; count: number }> by_expert: Array<{ name: string; category: string; title: string; total: number; replied: number; pending: number; avg_rating: number; rating_count: number }> daily_trend: Array<{ date: string; count: number }> by_user: Array<{ phone_masked: string; consultations: number; last_active: string | null }> }>('/admin/expert-activity'), // ===== 脱敏行动摘要 ===== previewSummary: (summary: Record) => request<{ savable_fields: string[]; preview: Record }>('/summaries/preview', { method: 'POST', body: JSON.stringify({ summary }), }), saveSummary: (sessionId: string | null, summary: Record) => request<{ id: number; summary: Record }>('/summaries', { method: 'POST', body: JSON.stringify({ session_id: sessionId, summary }), }), listSummaries: (limit?: number) => request<{ results: Array<{ id: number; session_id: string | null; summary: Record; created_at: string }> }>( `/summaries${limit ? `?limit=${limit}` : ''}`, ), // ===== 向量检索 ===== vectorSearchKnowledge: (q: string, limit?: number) => request<{ results: KnowledgeDoc[] }>( `/knowledge/vector-search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`, ), // ===== 销毁状态 ===== destroyStatus: (sid: string) => request<{ session_id: string; destroyed: boolean; destroy_log: Array> }>( `/sessions/${sid}/destroy-status`, ), // ===== 可观测性 ===== getMetrics: () => request>('/metrics'), // ===== 对话历史 ===== /** 列出历史会话(可选模式过滤) */ listHistorySessions: (mode?: 'normal' | 'confidential') => request<{ results: HistorySession[] }>( `/history/sessions${mode ? `?mode=${mode}` : ''}`, ), /** 获取会话历史消息(普通模式直接返回明文,保密模式返回 need_verify) */ getHistoryMessages: (sessionId: string) => request<{ session_id: string mode: 'normal' | 'confidential' need_verify: boolean message_count?: number messages?: HistoryMessage[] }>(`/history/sessions/${sessionId}`), /** 保密模式:验证密码后获取解密消息 */ verifyHistory: (sessionId: string, password: string) => request<{ session_id: string mode: 'confidential' verified: boolean messages: HistoryMessage[] }>(`/history/sessions/${sessionId}/verify`, { method: 'POST', body: JSON.stringify({ password }), }), /** 删除会话历史 */ deleteHistory: (sessionId: string) => request<{ deleted: boolean; session_id: string; count: number }>( `/history/sessions/${sessionId}`, { method: 'DELETE' }, ), }