import axios, { AxiosRequestConfig } from 'axios' interface ApiClient { get(url: string, config?: AxiosRequestConfig): Promise post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise delete(url: string, config?: AxiosRequestConfig): Promise } // API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api' // - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000) // - digital.99hui.com 同域部署:保持 '/api' // - 仅跨域部署时配置后端公网地址 function resolveBaseURL(): string { const cfg = (window as any).__APP_CONFIG__ if (cfg && cfg.apiBase) return cfg.apiBase as string const env = import.meta.env.VITE_API_BASE as string | undefined if (env) return env return '/api' } // 统一认证 token(由 uniapp 壳通过 URL 注入,见 utils/uniapp-bridge.ts) let _authToken: string | null = null export function setAuthToken(token: string | null) { _authToken = token } export function getAuthToken(): string | null { return _authToken } // 创建 axios 实例(复用现有项目模式) const createRequest = (config?: AxiosRequestConfig): ApiClient => { const request = axios.create({ baseURL: resolveBaseURL(), timeout: 30000, ...config }) // 请求拦截器:注入认证头 request.interceptors.request.use((cfg) => { if (_authToken) { cfg.headers = cfg.headers || {} ;(cfg.headers as any).Authorization = `Bearer ${_authToken}` } return cfg }) // 响应拦截器 request.interceptors.response.use( (res) => { const data = fixDatetimeTZ(res.data) if (data && data.code && data.code !== 200) { console.error('API Error:', data.message) return Promise.reject(new Error(data.message)) } // 解包标准响应 { code, message, data } if (data && data.code === 200) { return data.data !== undefined ? data.data : data } return data }, (err) => { const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误' console.error('Network Error:', msg) return Promise.reject(err) } ) // The response interceptor unwraps the API envelope before callers receive it. return request as unknown as ApiClient } // 递归修复时区标识(复用现有项目逻辑) function fixDatetimeTZ(obj: any): any { if (typeof obj === 'string') { return obj.replace(/T(\d{2}:\d{2}:\d{2})\+00:00/g, 'T$1\+08:00') } if (Array.isArray(obj)) return obj.map(fixDatetimeTZ) if (obj && typeof obj === 'object') { const result: any = {} for (const k in obj) result[k] = fixDatetimeTZ(obj[k]) return result } return obj } const request = createRequest() // ==================== 分身管理 API ==================== export interface Avatar { id: string name: string displayName: string description: string photoUrl?: string status: 'active' | 'inactive' | 'training' tokenBalance: number createdAt: string updatedAt: string config?: Record } // 获取分身列表 export const getAvatarList = (params?: { page?: number; limit?: number }) => request.get<{ data: Avatar[]; total: number }>('/avatar', { params }) // 获取分身详情 export const getAvatarDetail = (id: string) => request.get(`/avatar/${id}`) // 创建分身 export const createAvatar = (data: Partial) => request.post('/avatar', data) // 更新分身 export const updateAvatar = (id: string, data: Partial) => request.put(`/avatar/${id}`, data) export const uploadAvatarPhoto = (id: string, file: File) => { const form = new FormData() form.append('file', file) return request.post<{ photoUrl: string }>(`/avatar/${id}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) } // 删除分身 export const deleteAvatar = (id: string) => request.delete(`/avatar/${id}`) // ==================== Token 管理 API ==================== export interface TokenBalance { balance: number totalGranted: number totalConsumed: number } export interface TokenUsageSummary { avatarId: string source: string promptTokens: number completionTokens: number totalTokens: number requestCount: number } export interface TokenPaymentOrder { id: string orderNo: string planId: string paymentMethod: 'wechat' | 'alipay' payType: 'WECHAT' | 'ALIPAY' payWay: 'APP' | 'LITE' | 'JSAPI' pointsAmount: number price: number status: 'pending' | 'paid' | 'failed' providerStatus: string payMessage: string failureReason: string balance: number } // 获取 Token 余额 export const getTokenBalance = () => request.get('/token/balance') // 获取充值套餐 export const getRechargePlans = () => request.get>('/token/plans') // 创建会会支付订单;支付回调成功后积分到账。 export const chargeToken = ( planId: string, paymentMethod: 'wechat' | 'alipay', payScene: 'APP' | 'LITE' | 'JSAPI' ) => request.post('/token/charge', { planId, paymentMethod, payScene }) export const getTokenPaymentStatus = (orderId: string) => request.get(`/token/payment/${orderId}`) // 按分身和使用场景汇总 Token 消耗 export const getTokenUsage = () => request.get('/token/usage') // ==================== 授权管理 API ==================== export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover' export interface AvatarPermissionSettings { avatarId: string permissions: AvatarPermission[] takeoverReplyDelaySeconds: number disabledAvatarIds?: string[] } export const getAvatarPermissionSettings = (avatarId: string) => request.get(`/avatar/${avatarId}/permission-settings`) export const updateAvatarPermissionSettings = ( avatarId: string, permissions: AvatarPermission[], takeoverReplyDelaySeconds: number ) => request.put(`/avatar/${avatarId}/permission-settings`, { permissions, takeoverReplyDelaySeconds, }) export interface TakeoverStatus { enabled: boolean status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error' message: string pendingCount: number takeoverReplyDelaySeconds: number lastPolledAt: string | null } export const getTakeoverStatus = (avatarId: string) => request.get(`/avatar/${avatarId}/takeover/status`) export interface Authorization { id: string avatarId: string targetType: 'user' | 'organization' | 'application' targetId: string targetName: string permissions: string[] status: 'active' | 'inactive' takeoverEnabled: boolean takeoverMode: 'immediate' | 'delayed' takeoverDelaySeconds: number createdAt: string } export type AuthorizationInput = Pick< Authorization, 'targetType' | 'targetId' | 'targetName' | 'permissions' > // 获取授权列表 export const getAuthorizationList = (avatarId: string) => request.get(`/avatar/${avatarId}/authorizations`) // 添加授权 export const createAuthorization = (avatarId: string, data: AuthorizationInput) => request.post(`/avatar/${avatarId}/authorizations`, data) // 更新授权 export const updateAuthorization = (avatarId: string, data: Partial & { id: string }) => request.put(`/avatar/${avatarId}/authorizations`, data) // 删除授权 export const deleteAuthorization = (avatarId: string, authorizationId: string) => request.delete<{ id: string }>(`/avatar/${avatarId}/authorizations/${authorizationId}`) // 更新单聊接管配置 export const updateTakeoverConfig = (avatarId: string, data: { authorizationId: string takeoverEnabled: boolean takeoverMode?: 'immediate' | 'delayed' takeoverDelaySeconds?: number }) => request.put(`/avatar/${avatarId}/authorizations/takeover`, data) // ==================== 组织管理 API ==================== export interface Organization { id: string name: string description: string emoji: string type: 'team' | 'company' | 'community' role: 'admin' | 'member' | 'viewer' memberCount: number createdAt: string } export interface CreateOrganizationInput { name: string desc?: string emoji?: string type?: 'team' | 'company' | 'community' } // 获取组织列表 export const getOrganizationList = (params?: any) => request.get<{ data: Organization[]; total: number }>('/organizations', { params }) // 创建组织 export const createOrganization = (data: CreateOrganizationInput) => request.post('/organizations', data) // ==================== 知识库管理 API ==================== export interface KnowledgeDoc { id: string avatarId: string filename: string fileType: string fileSize: number fileUrl: string status: string filePresent?: boolean vectorized?: boolean embeddingModel?: string chunkCount?: number errorMessage?: string indexStage?: string indexProgress?: number createdAt: string } export interface QAPair { id: string avatarId: string question: string answer: string enabled?: boolean createdAt: string updatedAt: string } export interface SearchResult { docId: string filename: string fileType: string snippet: string score: number } // 文档列表 export const getKnowledgeDocs = (avatarId: string) => request.get(`/avatar/${avatarId}/knowledge/docs`) const KNOWLEDGE_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024 const uploadKnowledgeChunk = async ( avatarId: string, uploadId: string, chunkIndex: number, chunk: Blob, onProgress?: (loaded: number) => void ) => { const form = new FormData() form.append('file', chunk, `chunk-${chunkIndex}`) let reportedLoaded = 0 for (let attempt = 1; attempt <= 3; attempt += 1) { try { await request.post( `/avatar/${avatarId}/knowledge/uploads/${uploadId}/chunks/${chunkIndex}`, form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 2 * 60 * 1000, onUploadProgress: (event) => { reportedLoaded = Math.max(reportedLoaded, Math.min(event.loaded, chunk.size)) onProgress?.(reportedLoaded) } } ) return } catch (error: any) { const status = Number(error?.response?.status || 0) const retryable = !status || status === 408 || status === 429 || status >= 500 if (!retryable || attempt === 3) throw error await new Promise((resolve) => window.setTimeout(resolve, attempt * 800)) } } } // 大文件拆成 5MB 分片,避免生产代理的请求体限制拦截整个文件。 export const uploadKnowledgeDoc = async ( avatarId: string, file: File, onUploadProgress?: (loaded: number, total: number) => void ) => { if (file.size > KNOWLEDGE_UPLOAD_CHUNK_SIZE) { const totalChunks = Math.ceil(file.size / KNOWLEDGE_UPLOAD_CHUNK_SIZE) const upload: any = await request.post(`/avatar/${avatarId}/knowledge/uploads`, { filename: file.name, fileSize: file.size, totalChunks }) let uploadedBytes = 0 for (let index = 0; index < totalChunks; index += 1) { const start = index * KNOWLEDGE_UPLOAD_CHUNK_SIZE const chunk = file.slice(start, Math.min(start + KNOWLEDGE_UPLOAD_CHUNK_SIZE, file.size)) await uploadKnowledgeChunk( avatarId, upload.uploadId, index, chunk, (chunkLoaded) => onUploadProgress?.(uploadedBytes + chunkLoaded, file.size) ) uploadedBytes += chunk.size onUploadProgress?.(uploadedBytes, file.size) } return request.post( `/avatar/${avatarId}/knowledge/uploads/${upload.uploadId}/complete`, undefined, { timeout: 2 * 60 * 1000 } ) } const form = new FormData() form.append('file', file) return request.post(`/avatar/${avatarId}/knowledge/docs`, form, { headers: { 'Content-Type': 'multipart/form-data' }, // A slow mobile uplink must not be mistaken for a failed upload. timeout: 10 * 60 * 1000, onUploadProgress: (event) => onUploadProgress?.(event.loaded, event.total || file.size) }) } // 删除文档 export const deleteKnowledgeDoc = (avatarId: string, docId: string) => request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`) export const retryKnowledgeDoc = (avatarId: string, docId: string) => request.post(`/avatar/${avatarId}/knowledge/docs/${docId}/retry`) // 标准问答对列表 export const getQAPairs = (avatarId: string) => request.get(`/avatar/${avatarId}/knowledge/qa`) // 创建问答对 export const createQAPair = (avatarId: string, data: { question: string; answer: string; enabled?: boolean }) => request.post(`/avatar/${avatarId}/knowledge/qa`, data) // 更新问答对 export const updateQAPair = (avatarId: string, id: string, data: { question: string; answer: string; enabled?: boolean }) => request.put(`/avatar/${avatarId}/knowledge/qa/${id}`, data) // 切换问答对启用状态 export const setQaEnabled = (avatarId: string, id: string, enabled: boolean) => request.put(`/avatar/${avatarId}/knowledge/qa/${id}/enabled`, { enabled }) // 删除问答对 export const deleteQAPair = (avatarId: string, id: string) => request.delete(`/avatar/${avatarId}/knowledge/qa/${id}`) // 文档向量检索 export const searchKnowledge = (avatarId: string, q: string, topK = 5) => request.get(`/avatar/${avatarId}/knowledge/search`, { params: { q, top_k: topK } }) // ==================== 数字分身聊天 API ==================== export interface ChatMessage { role: 'user' | 'assistant' content: string attachmentIds?: string[] } export interface ChatResponse { answer: string source: 'qa' | 'knowledge' | 'vision' | 'qwen' references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }> } export interface ChatAttachment { id: string avatarId: string filename: string mimeType: string fileSize: number status: 'processing' | 'ready' | 'failed' category: 'general_image' | 'document' | 'medical_document' | 'medical_image' summary: string warning: string expiresAt: string } export interface ChatPayload { message: string attachmentIds?: string[] history?: ChatMessage[] } export const sendAvatarChat = (avatarId: string, payload: ChatPayload) => request.post(`/avatar/${avatarId}/chat`, payload) export interface PublicAvatar { id: string name: string displayName: string description?: string photoUrl?: string emoji?: string status: 'active' | 'inactive' | 'training' } export const createAvatarShareLink = (avatarId: string) => request.post<{ shareToken: string }>(`/avatar/${avatarId}/share`) export const getPublicAvatar = (shareToken: string) => request.get(`/public/avatar/${shareToken}`) export const sendPublicAvatarChat = (shareToken: string, payload: ChatPayload) => request.post(`/public/avatar/${shareToken}/chat`, payload) const imageForm = (file: File) => { const form = new FormData() form.append('file', file) return form } export const uploadAvatarChatImage = (avatarId: string, file: File) => request.post(`/avatar/${avatarId}/chat/images`, imageForm(file), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000 }) export const uploadPublicAvatarChatImage = (shareToken: string, file: File) => request.post(`/public/avatar/${shareToken}/chat/images`, imageForm(file), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000 }) type ChatStreamHandlers = { onMeta: (meta: Pick) => void onDelta: (content: string) => void } const streamChat = async (path: string, payload: ChatPayload, handlers: ChatStreamHandlers) => { const headers: Record = { 'Content-Type': 'application/json', Accept: 'text/event-stream' } if (_authToken) headers.Authorization = `Bearer ${_authToken}` const response = await fetch(`${resolveBaseURL()}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) }) if (!response.ok) { const errorBody = await response.json().catch(() => null) throw new Error(errorBody?.detail || errorBody?.message || `对话请求失败(${response.status})`) } if (!response.body) throw new Error('对话响应为空,请稍后重试') const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() buffer += decoder.decode(value || new Uint8Array(), { stream: !done }) const events = buffer.split('\n\n') buffer = events.pop() || '' for (const eventBlock of events) { const event = eventBlock.match(/^event:\s*(.+)$/m)?.[1] || 'message' const data = eventBlock.match(/^data:\s*(.+)$/m)?.[1] if (!data) continue const parsed = JSON.parse(data) if (event === 'meta') handlers.onMeta(parsed) if (event === 'delta') handlers.onDelta(parsed.content || '') if (event === 'error') throw new Error(parsed.message || '对话暂时不可用') } if (done) break } } export const streamAvatarChat = (avatarId: string, payload: ChatPayload, handlers: ChatStreamHandlers) => streamChat(`/avatar/${avatarId}/chat/stream`, payload, handlers) export const streamPublicAvatarChat = (shareToken: string, payload: ChatPayload, handlers: ChatStreamHandlers) => streamChat(`/public/avatar/${shareToken}/chat/stream`, payload, handlers) // ==================== 会会用户资料 API ==================== export interface UserProfile { userId: string nickname: string avatarUrl: string } // 获取会会系统中的用户头像与昵称 export const getUserProfile = () => request.get('/user/profile') // ==================== 会会短信验证码登录 API ==================== export interface SmsLoginResult { token: string user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string } huihui: { userId: string; nickname: string; avatarUrl: string } } // 发送短信验证码(演示模式会额外返回 devCode / dev 标记) export const sendSmsCode = (phone: string) => request.post<{ sent: boolean; devCode?: string; dev?: boolean }>('/huihui/sms/send', { phone }) // 短信验证码登录 export const loginBySms = (phone: string, code: string) => request.post('/huihui/sms/login', { phone, code }) // 账号密码登录(会会 loginType=password) export const loginByPassword = (account: string, password: string) => request.post('/huihui/pwd/login', { account, password }) // Validate a production Huihui access token and exchange it for an app session. export const loginByHuihuiToken = (token: string) => request.post('/huihui/token/login', { token }) // 当前登录用户 export const getCurrentUser = () => request.get('/huihui/me') // 退出登录 export const logoutUser = () => request.post('/huihui/logout') export default request