feat: BossAi 老板AI助手初始版本
包含功能: - 法务参谋/经营决策/生活助理/情绪中心四大AI对话模块 - 专家咨询系统(用户选定→专家确认→咨询→回复→评价) - 角色登录(用户/专家/管理员)+ ProtectedRoute 权限控制 - 管理后台(用户管理/专家管理/AI使用统计/专家活跃度) - 专家工作台(待确认服务/待回复咨询/已回复咨询/服务中用户) - 服务关系取消(用户和专家均可取消,需自己确认) - 全页面移动端响应式适配 - 知识库RAG检索 - 对话历史(普通/保密模式) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
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<string, string> {
|
||||
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<Response> {
|
||||
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<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: { timeout?: number; retries?: number },
|
||||
): Promise<T> {
|
||||
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<T>
|
||||
} 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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestWithRetry<T>(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<SessionResponse>('/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
|
||||
ask: (sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode) =>
|
||||
requestWithRetry<AskResponse>(`/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<Record<string, unknown>>('/profile/me', {}),
|
||||
|
||||
/** 事实追问分析:返回追问问题或标记事实已充分 */
|
||||
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
|
||||
}>(`/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<AskResponse>) => 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<FileInspectResponse>
|
||||
},
|
||||
|
||||
addKnowledge: (data: {
|
||||
title: string
|
||||
content: string
|
||||
source?: string
|
||||
doc_type?: string
|
||||
region?: string | null
|
||||
effective_date?: string | null
|
||||
keywords?: string | null
|
||||
}) =>
|
||||
request<KnowledgeDoc>('/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<Expert>(`/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<Pick<Expert, 'name' | 'category' | 'title' | 'bio' | 'expertise' | 'avatar_url' | 'status'>>) =>
|
||||
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: () => request<{ results: Array<{ id: number; phone: string; name: string; created_at: string }> }>('/admin/users'),
|
||||
|
||||
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<string, unknown>) =>
|
||||
request<{ savable_fields: string[]; preview: Record<string, unknown> }>('/summaries/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ summary }),
|
||||
}),
|
||||
|
||||
saveSummary: (sessionId: string | null, summary: Record<string, unknown>) =>
|
||||
request<{ id: number; summary: Record<string, unknown> }>('/summaries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sessionId, summary }),
|
||||
}),
|
||||
|
||||
listSummaries: (limit?: number) =>
|
||||
request<{ results: Array<{ id: number; session_id: string | null; summary: Record<string, unknown>; 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<Record<string, unknown>> }>(
|
||||
`/sessions/${sid}/destroy-status`,
|
||||
),
|
||||
|
||||
// ===== 可观测性 =====
|
||||
getMetrics: () => request<Record<string, unknown>>('/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' },
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user