/** BossAi 2.0 API 客户端 * 在 v1.0 基础上新增:案卡 CRUD、老板台聚合、我的团队、快捷指令、风险推送 * 共享同一后端,新增端点向后兼容 */ import type { AskResponse, EmotionMode, Expert, ExpertService, Consultation, FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc, Scene, SessionMode, SessionResponse, Settings, Matter, MatterBoard, DashboardToday, DashboardSummary, TimelineItem, RiskAlert, ExpertRelation, QuickCommands, } from '@/types' const BASE = '/api' const DEFAULT_TIMEOUT = 30000 const LONG_TIMEOUT = 60000 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) } }, } function authHeaders(): Record { const token = typeof window !== 'undefined' ? localStorage.getItem('bossai_token') : null return token ? { Authorization: `Bearer ${token}` } : {} } 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) } } 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 })) 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 await new Promise(r => setTimeout(r, 1000 * (attempt + 1))) } } throw lastError! } async function request(path: string, init?: RequestInit): Promise { return requestWithRetry(path, init) } export const api = { // ===== 基础 ===== health: () => request<{ status: string; service: string }>('/health'), // ===== 认证 ===== 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 }), }), // ===== 会话与提问(与 v1.0 兼容) ===== createSession: (mode: SessionMode) => request('/sessions', { method: 'POST', body: JSON.stringify({ mode }) }), /** 检查内存 session 是否存活(后端重启后内存 session 会丢失) */ sessionAlive: (sid: string) => request<{ alive: boolean }>(`/sessions/${sid}/alive`), 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 }), askAnalyze: ( sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode, factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>, topic?: 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 }>; element_template?: Record; }>(`/sessions/${sid}/ask-analyze`, { method: 'POST', body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers, topic }), }), 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 }) 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 }, // ===== 画像 ===== 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'), // ===== 要素化立案 ===== elementFiling: (sid: string, question: string, causeOfAction: string, factAnswers: Array<{ dim_key: string; dim_desc: string; answer: string }>, template?: Record, ) => request<{ cause_of_action: string; elements: Array<{ key: string; label: string; value: string }>; claims: string[]; filing_text: string; }>(`/sessions/${sid}/element-filing`, { method: 'POST', body: JSON.stringify({ question, cause_of_action: causeOfAction, fact_answers: factAnswers, template }), }), // ===== 话题与示例 ===== getTopics: (scene: Scene = 'legal', existing?: string[]) => request<{ topics: Array<{ title: string; desc: string; samples: string[] }> }>('/topics', { method: 'POST', body: JSON.stringify({ scene, existing }), }), generateSamples: (scene: Scene = 'legal', topic?: string, existing?: string[]) => request<{ samples: string[] }>('/samples', { method: 'POST', body: JSON.stringify({ scene, topic, existing }), }), // ===== 每日热点问题 ===== getDailyTopics: (scene: Scene) => request<{ scene: string; questions: string[] }>(`/topics/daily?scene=${scene}`), recordSampleClick: (scene: Scene, question: string) => request<{ recorded: boolean }>('/topics/click', { method: 'POST', body: JSON.stringify({ scene, question }), }), // ===== 管理端:热点问题 ===== adminGetDailyTopics: (date?: string) => request<{ results: Array<{ id: number; date: string; scene: string; questions: string[]; source: string; created_at: string }> }>( `/admin/daily-topics${date ? `?date=${date}` : ''}`, ), adminUpdateDailyTopics: (data: { scene: string; questions: string[]; date?: string }) => request<{ updated: boolean; topic: { id: number; date: string; scene: string; questions: string[]; source: string } }>( '/admin/daily-topics', { method: 'POST', body: JSON.stringify(data) }, ), adminGenerateDailyTopics: () => request<{ generated: boolean; results: Record }>( '/admin/daily-topics/generate', { method: 'POST' }, ), adminSampleClickStats: (days = 14) => request<{ results: Array<{ scene: string; question: string; clicks: number }> }>( `/admin/sample-clicks?days=${days}`, ), // ===== 知识库 ===== 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[]; counts: Record; total: number }>(`/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' }), vectorSearchKnowledge: (q: string, limit?: number) => request<{ results: KnowledgeDoc[] }>(`/knowledge/vector-search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`), // ===== 设置 ===== getSettings: () => request<{ settings: Settings }>('/settings'), updateSettings: (settings: Settings) => request<{ updated: boolean; settings: Settings }>('/settings', { method: 'PUT', body: JSON.stringify({ settings }) }), // ===== 专家库(v1.0 兼容) ===== 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 }), }), cancelMyService: (serviceId: number) => request<{ cancelled: boolean }>(`/my/services/${serviceId}/cancel`, { method: 'POST' }), // ===== 专家工作台 ===== expertWorkbench: () => request<{ expert: Expert; services: ExpertService[]; consultations: Consultation[] }>('/expert/workbench'), acceptService: (serviceId: number) => request<{ accepted: boolean }>(`/expert/services/${serviceId}/accept`, { 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}` : ''}`), adminCreateExpert: (data: { name: string; phone: string; password: string; category: string; title?: string; bio?: string; expertise?: string; avatar_url?: string | null; status?: string; }) => request<{ created: boolean; expert: Expert }>('/admin/experts', { method: 'POST', body: JSON.stringify(data), }), adminDeleteExpert: (expertId: number) => request<{ deleted: boolean; expert_id: number }>(`/admin/experts/${expertId}`, { method: 'DELETE' }), 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) }), 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, 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'), 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'), // ===== 对话历史 ===== listHistorySessions: (mode?: 'normal' | 'confidential') => request<{ results: HistorySession[] }>(`/history/sessions${mode ? `?mode=${mode}` : ''}`), 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' }), // ===== 2.0 新增:案卡 ===== /** 案卡看板(按状态分组) */ getMatters: (status?: string) => request<{ board: MatterBoard; total: number }>(`/matters${status ? `?status=${status}` : ''}`), /** 创建案卡(绑定会话) */ createMatter: (data: { scene: Scene; title: string; session_id?: string }) => request('/matters', { method: 'POST', body: JSON.stringify(data) }), /** 案卡详情 */ getMatter: (id: number) => request(`/matters/${id}`), /** 通过 session_id 查询案卡(断点续接时恢复 matterId) */ getMatterBySession: (sid: string) => request(`/matters/by-session/${sid}`), /** 更新案卡状态 */ updateMatterStatus: (id: number, status: Matter['status']) => request<{ updated: boolean; status: string }>(`/matters/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }), /** 闭环案卡 */ closeMatter: (id: number, executionResult?: string, executionNote?: string) => request<{ closed: boolean; closed_at: string }>(`/matters/${id}/close`, { method: 'POST', body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }), }), // ===== 2.0 新增:老板台 ===== getDashboardToday: () => request('/dashboard/today'), getDashboardSummary: () => request('/dashboard/summary'), getDashboardTimeline: () => request<{ results: TimelineItem[] }>('/dashboard/timeline'), // ===== 2.0 新增:我的团队 ===== getTeam: () => request<{ results: ExpertRelation[] }>('/team'), // ===== 2.0 新增:快捷指令 ===== getQuickCommands: () => request('/quick-commands'), // ===== 2.0 新增:风险推送 ===== getRiskAlerts: (unread?: boolean) => request<{ results: RiskAlert[] }>(`/risk-alerts${unread ? '?unread=true' : ''}`), readRiskAlert: (id: number) => request<{ read: boolean }>(`/risk-alerts/${id}/read`, { method: 'POST' }), scanRiskAlerts: () => request<{ scanned: number; created: number }>('/risk-alerts/scan', { method: 'POST' }), // ===== 2.0 新增:决策日志 ===== getDecisionsLog: (month?: string) => request<{ results: Matter[] by_month: Record stats: { total: number; executed: number; execution_rate: number } }>(`/decisions-log${month ? `?month=${month}` : ''}`), updateExecutionResult: (matterId: number, executionResult: string, executionNote?: string) => request<{ updated: boolean }>(`/matters/${matterId}/execution`, { method: 'PATCH', body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }), }), // ===== 可观测性 ===== getMetrics: () => request>('/metrics'), }