import { useState, useRef, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download, TrendingUp, UserCheck, Phone } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx' import { saveAs } from 'file-saver' import api from '../lib/api' import { employeeApi } from '../lib/api-services' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report' /** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */ function parseInlineBold(text: string): TextRun[] { const runs: TextRun[] = [] const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g let lastIndex = 0 let match while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { runs.push(new TextRun({ text: text.slice(lastIndex, match.index) })) } if (match[2]) { runs.push(new TextRun({ text: match[2], bold: true })) } else if (match[3]) { runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 })) } lastIndex = regex.lastIndex } if (lastIndex < text.length) { runs.push(new TextRun({ text: text.slice(lastIndex) })) } return runs.length ? runs : [new TextRun({ text })] } /** 导出 Markdown 文本为 Word 文档 */ async function exportMarkdownToWord(markdown: string, fileName: string) { const lines = markdown.split('\n') const children: (Paragraph | Table)[] = [] let i = 0 while (i < lines.length) { const line = lines[i] if (!line.trim()) { i++; continue } if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) { const headerCells = line.split('|').map(c => c.trim()).filter(Boolean) i += 2 const rows: TableRow[] = [] rows.push(new TableRow({ children: headerCells.map(text => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })], shading: { fill: 'F3F4F6' }, })), })) while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean) rows.push(new TableRow({ children: cells.map(text => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text })] })], })), })) i++ } children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } })) continue } if (line.startsWith('### ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] })) } else if (line.startsWith('## ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] })) } else if (line.startsWith('# ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] })) } else if (line.startsWith('> ')) { children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } })) } else if (line.startsWith('- ') || line.startsWith('* ')) { children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } })) } else if (/^\d+\.\s/.test(line)) { children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } })) } else if (line === '---' || line === '***') { children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } })) } else { children.push(new Paragraph({ children: parseInlineBold(line) })) } i++ } const doc = new Document({ numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] }, sections: [{ children }], }) const blob = await Packer.toBlob(doc) saveAs(blob, fileName) } // 通用 AI 历史记录 hook function useAIHistory(type: 'predict' | 'review' | 'case') { const queryClient = useQueryClient() const queryKey = [`ai-history-${type}`] const { data: history } = useQuery({ queryKey, queryFn: async () => { const res = await api.get(`/ai/conversations?type=${type}`) as any return res.data }, }) const saveMutation = useMutation({ mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => { const res = await api.post('/ai/conversations', { title: `${type}:${title}`, messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }], }) as any return res.data }, onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const loadHistory = useCallback(async (id: string) => { const res = await api.get(`/ai/conversations/${id}`) as any return res.data }, []) return { history, saveMutation, deleteMutation, loadHistory } } // 通用历史记录栏组件 function HistoryBar({ history, onLoad, onDelete }: { history: any[] onLoad: (id: string) => void onDelete: (id: string) => void }) { return (
{history.length > 0 ? history.map((c: any) => (
onLoad(c.id)}> {c.title.replace(/^(predict:|review:|case:)/, '')} {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
)) :
暂无历史记录
}
) } interface Message { role: 'user' | 'assistant' content: string } const QUICK_QUESTIONS = [ '员工入职没签合同怎么办?', '加班费怎么算?', '辞退员工需要赔多少?', '试用期最长可以约定几个月?', ] export default function AIAssistant() { const [tab, setTab] = useState('chat') const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [ { key: 'chat', label: '智能问答', icon: Bot }, { key: 'predict', label: '风险预测', icon: Sparkles }, { key: 'review', label: '合同审查', icon: FileSearch }, { key: 'case', label: '案例匹配', icon: Scale }, { key: 'hr-report', label: '人力报告', icon: TrendingUp }, { key: 'knowledge', label: '知识库', icon: BookOpen }, ] return (

AI 合规顾问

智能分析用工风险,辅助合同审查与合规决策

{tabs.map((t) => { const Icon = t.icon return ( ) })}
{tab === 'chat' && } {tab === 'predict' && } {tab === 'review' && } {tab === 'case' && } {tab === 'hr-report' && } {tab === 'knowledge' && }
) } function ChatTab() { const queryClient = useQueryClient() const [messages, setMessages] = useState([ { role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }, ]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [recording, setRecording] = useState(false) const [showHistory, setShowHistory] = useState(false) const [currentConvId, setCurrentConvId] = useState(null) const [showConsultModal, setShowConsultModal] = useState(false) const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' }) const scrollRef = useRef(null) const recognitionRef = useRef(null) const saveTimerRef = useRef(null) const { data: conversations } = useQuery({ queryKey: ['ai-conversations'], queryFn: async () => { const res = await api.get('/ai/conversations?type=chat') as any return res.data }, }) const deleteConvMutation = useMutation({ mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }), }) const consultMutation = useMutation({ mutationFn: async (data: typeof consultForm) => { const res = await api.post('/ai/consultation', data) as any return res.data }, onSuccess: () => { toast.success('已提交咨询请求,专业律师将尽快与您联系') setShowConsultModal(false) setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' }) }, onError: (err: any) => { toast.error(err?.message || '提交失败,请稍后重试') }, }) useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight) }, [messages]) // 自动保存会话(debounce) useEffect(() => { if (messages.length <= 1) return if (saveTimerRef.current) clearTimeout(saveTimerRef.current) saveTimerRef.current = setTimeout(async () => { const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}` if (currentConvId) { await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {}) } else { const res = await api.post('/ai/conversations', { title, messages }) as any if (res.data?.id) { setCurrentConvId(res.data.id) queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }) } } }, 2000) return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, [messages]) const loadConversation = async (id: string) => { try { const res = await api.get(`/ai/conversations/${id}`) as any if (res.data?.messages) { setMessages(res.data.messages) setCurrentConvId(id) setShowHistory(false) } } catch {} } const newConversation = () => { setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }]) setCurrentConvId(null) setShowHistory(false) } const toggleVoice = () => { const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition if (!SpeechRecognition) { toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge') return } if (recording) { recognitionRef.current?.stop() setRecording(false) return } const recognition = new SpeechRecognition() recognition.lang = 'zh-CN' recognition.continuous = false recognition.interimResults = false recognition.onresult = (event: any) => { const transcript = event.results[0]?.[0]?.transcript || '' setInput((prev) => prev + transcript) } recognition.onerror = () => setRecording(false) recognition.onend = () => setRecording(false) recognition.start() recognitionRef.current = recognition setRecording(true) } const send = async (text?: string) => { const content = text || input.trim() if (!content || loading) return const newMessages = [...messages, { role: 'user' as const, content }] setMessages([...newMessages, { role: 'assistant', content: '' }]) setInput('') setLoading(true) try { const token = useAuthStore.getState().accessToken const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 60 * 1000) const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream' const response = await fetch(chatUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ messages: newMessages }), signal: controller.signal, }) clearTimeout(timeoutId) if (!response.ok) { const errData = await response.json().catch(() => null) throw new Error(errData?.error?.message || '请求失败') } const reader = response.body?.getReader() const decoder = new TextDecoder() let accumulated = '' let buffer = '' let rafId: number | null = null let pendingFlush = false // 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染 const flush = () => { pendingFlush = false rafId = null setMessages([...newMessages, { role: 'assistant', content: accumulated }]) } const scheduleFlush = () => { if (!pendingFlush) { pendingFlush = true rafId = requestAnimationFrame(flush) } } if (reader) { 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) { if (line.startsWith('data: ')) { const data = line.slice(6).trim() if (data === '[DONE]') continue try { const parsed = JSON.parse(data) if (parsed.delta) { accumulated += parsed.delta scheduleFlush() } if (parsed.error) { throw new Error(parsed.error) } } catch (parseErr: any) { // 只有业务错误(有 message 且不是 SyntaxError)才抛出 if (parseErr instanceof SyntaxError) { // JSON 解析失败,可能是 SSE 分块截断,跳过等下一块 continue } throw parseErr } } } } // 确保最后一批内容被刷新 if (rafId) cancelAnimationFrame(rafId) if (accumulated) { setMessages([...newMessages, { role: 'assistant', content: accumulated }]) } } if (!accumulated) { setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }]) } } catch (err: any) { const isTimeout = err.name === 'AbortError' setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }]) } finally { setLoading(false) } } return (
{/* 顶部操作栏 */}
{conversations && conversations.length > 0 && ( {conversations.length} 条历史 )}
{/* 历史会话列表 */} {showHistory && (
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
loadConversation(c.id)}>{c.title.replace(/^chat:/, '')} {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
)) :
暂无历史会话
}
)}
{messages.map((msg, i) => (
{msg.role === 'assistant' ? ( msg.content ? (
{msg.content}
) : loading && i === messages.length - 1 ? ( 思考中... ) : null ) : ( msg.content )}
))}
{/* 快捷问题 */} {messages.length <= 1 && (
{QUICK_QUESTIONS.map((q) => ( ))}
)} {/* 输入框 */}
setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="输入问题..." disabled={loading} />
{/* 转人工咨询 Modal */} {showConsultModal && ( setShowConsultModal(false)}>

服务说明

· 法律咨询:专业律师在线解答劳动法问题

· 仲裁代理:律师代理劳动仲裁案件(付费服务)

· 出庭服务:律师代理法院诉讼(付费服务)

提交后律师将在 24 小时内与您联系。

setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" />