import { useState, useRef, useEffect } from 'react' import { useSearchParams, useLocation } from 'react-router-dom' import { toast } from 'sonner' import { Send, Upload, Loader2, MessageSquare, Lock, Flame, Users, HelpCircle, RefreshCw, Check, CheckCircle, FileText, BookOpen, Sparkles, Download, ChevronRight, ChevronLeft, } from 'lucide-react' import { RiskCardView } from './RiskCardView' import { Markdown } from './Markdown' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog' import { api } from '@/lib/api' import { getPhone } from '@/lib/auth' import { cn } from '@/lib/utils' import type { AskResponse, EmotionMode, ExpertService, HistoryMessage, KnowledgeDoc, RiskCard, Scene, SessionMode } from '@/types' /** 画像字段英文值 → 中文显示映射 */ const PROFILE_VALUE_LABELS: Record = { // 企业规模 micro: '微型(<10人)', small: '小型(<50人)', medium: '中型(<200人)', large: '大型(>200人)', // 角色 owner: '老板/创始人', manager: '管理者', ceo: 'CEO', director: '总监', // 风险偏好 conservative: '保守型', moderate: '稳健型', aggressive: '进取型', // 决策风格 'data-driven': '数据驱动型', intuitive: '直觉型', collaborative: '协作型', // 沟通偏好 concise: '简洁', detailed: '详细', structured: '结构化', // 通用 yes/no yes: '是', no: '否', // 行业 retail: '零售', manufacturing: '制造业', tech: '科技', service: '服务业', finance: '金融', realestate: '房地产', education: '教育', healthcare: '医疗健康', construction: '建筑', logistics: '物流', agriculture: '农业', hospitality: '餐饮酒店', // 常见 key_facts 英文值 true: '是', false: '否', none: '无', unknown: '未知', 'n/a': '不适用', pending: '待定', active: '活跃', inactive: '停用', profit: '盈利', loss: '亏损', expansion: '扩张', contraction: '收缩', stable: '稳定', growth: '增长中', declining: '下滑中', } /** 画像字段标签(dim_desc)英文 → 中文映射 */ const PROFILE_DIM_LABELS: Record = { // 标准画像字段 industry: '行业', company_size: '企业规模', role: '角色', risk_tolerance: '风险偏好', decision_style: '决策风格', communication_style: '沟通偏好', // key_facts 常见 key region: '地区', company_type: '公司类型', team_ability: '团队能力', communication: '沟通情况', medical_proof: '医疗证明', contract_terms: '合同条款', medical_period: '医疗期', financial_status: '财务状况', injury_situation: '工伤情况', legal_procedures: '法律程序', market_potential: '市场潜力', alternative_option: '备选方案', work_injury_details: '工伤详情', alternative_solution: '替代方案', work_injury_insurance: '工伤保险', performance_assessment: '绩效评估', relevant_legal_provisions: '相关法律条款', employee_count: '员工人数', revenue: '营收', profit_status: '盈利状况', business_model: '商业模式', funding_stage: '融资阶段', partnership: '合伙关系', shareholder_structure: '股权结构', contract_dispute: '合同纠纷', debt_amount: '债务金额', tax_risk: '税务风险', litigation_status: '诉讼状态', employee_role: '员工角色', salary_status: '薪资状况', social_security: '社保', severance: '经济补偿', working_years: '工作年限', probation_period: '试用期', termination_reason: '解雇原因', overtime_status: '加班情况', export_capability: '出口能力', expansion_plan: '扩张计划', competitor: '竞争对手', customer_concentration: '客户集中度', supply_chain: '供应链', cash_flow: '现金流', asset_status: '资产状况', liability: '负债', } /** 将画像字段的英文标签翻译为中文 */ function translateDimLabel(label: string): string { const lower = label.toLowerCase().trim() return PROFILE_DIM_LABELS[lower] ?? label } /** 将画像字段的英文值翻译为中文显示 */ function translateProfileValue(value: string): string { // key_facts 类的值可能是中文也可能是英文,统一查映射表 const lower = value.toLowerCase().trim() return PROFILE_VALUE_LABELS[lower] ?? value } export interface ChatPanelMode { value: SessionMode label: string desc: string Icon: typeof MessageSquare } export interface ChatPanelProps { /** 业务场景,路由后端 system prompt */ scene: Scene /** 示例问题 */ samples: string[] /** 当前话题(用于 AI 换一批时定向生成) */ topic?: string /** 允许的会话模式(默认全部) */ modes?: ChatPanelMode[] /** 默认会话模式 */ defaultMode?: SessionMode /** 情绪模式(仅 emotion 场景),由页面控制并传入 */ emotionMode?: EmotionMode /** 情绪模式选择器(由页面渲染,通过 children 注入) */ modeSelector?: React.ReactNode /** 输入框占位符 */ placeholder?: string /** 是否显示风险卡片(法务场景显示,其他场景可选) */ showRiskCard?: boolean /** 文件上传支持 */ enableUpload?: boolean /** 卡片底部脚注(如免责声明) */ footer?: React.ReactNode } interface Message { role: 'user' | 'assistant' content: string risk?: AskResponse['risk'] answer?: string | null model?: string inferenceLocation?: string fallbackUsed?: boolean /** 知识库引用来源 */ ragContext?: KnowledgeDoc[] /** 流式输出中 */ streaming?: boolean /** 事实追问回答(样式区分) */ isFactAnswer?: boolean } /** 知识库文档类型标签映射 */ const DOC_TYPE_LABELS: Record = { law: '法规', case: '案例', guide: '指南', industry: '行业', template: '模板', } const DEFAULT_MODES: ChatPanelMode[] = [ { value: 'normal', label: '普通模式', desc: '日常经营,可保存历史', Icon: MessageSquare }, { value: 'confidential', label: '保密模式', desc: '加密保存,文件自动过期', Icon: Lock }, { value: 'ephemeral', label: '阅后即焚', desc: '退出即销毁,不留痕迹', Icon: Flame }, ] /** 生成风险卡 Markdown 文本(用于分享/导出) */ function riskCardToMarkdown(card: RiskCard, answer?: string | null): string { const lines: string[] = [] lines.push(`# 风险判断`) lines.push('') lines.push(`**风险等级:** ${card.level}`) lines.push('') lines.push(`**结论:** ${card.conclusion}`) lines.push('') if (card.actions.length > 0) { lines.push('## 立即行动') card.actions.forEach((a) => lines.push(`- ${a}`)) lines.push('') } if (card.evidence.length > 0) { lines.push('## 需保存证据') lines.push(card.evidence.join('、')) lines.push('') } if (answer) { lines.push('## 模型分析') lines.push(answer) lines.push('') } if (card.escalation) { lines.push('> 建议人工专业复核:本事项可能造成明显损失或涉及重大风险。') } return lines.join('\n') } /** 通用对话面板:会话创建 → 提问 → 风险卡/答复 → 文件上传 → 隐私急停 * 2.0 新增:案卡接入、风险卡置顶、起诉状三步向导、URL 参数预填、location state 传入 expertId */ export function ChatPanel({ scene, samples, topic, modes = DEFAULT_MODES, defaultMode = 'confidential', emotionMode, modeSelector, placeholder = '描述你的问题…', showRiskCard = true, enableUpload = true, footer, }: ChatPanelProps) { const [mode, setMode] = useState(defaultMode) const [sid, setSid] = useState(null) const [question, setQuestion] = useState('') const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(false) const [dynamicSamples, setDynamicSamples] = useState(samples) const [refreshing, setRefreshing] = useState(false) /** 当天热点问题池(从后端加载的全部问题)和当前批次起始索引 */ const [dailyPool, setDailyPool] = useState([]) const [poolIndex, setPoolIndex] = useState(0) const BATCH_SIZE = 12 // ===== 2.0 新增:案卡状态 ===== const [matterId, setMatterId] = useState(null) // ===== 2.0 新增:风险卡置顶(仅显示结论摘要) ===== const [showCloseDialog, setShowCloseDialog] = useState(false) const [closeResult, setCloseResult] = useState<'顺利' | '受阻' | '未执行'>('顺利') const [closeNote, setCloseNote] = useState('') const [closing, setClosing] = useState(false) // ===== 2.0 新增:起诉状三步向导 ===== const [filingWizardOpen, setFilingWizardOpen] = useState(false) const [filingStep, setFilingStep] = useState(1) const [filingElements, setFilingElements] = useState>([]) const [filingClaims, setFilingClaims] = useState([]) const [filingText, setFilingText] = useState('') const [filingCause, setFilingCause] = useState('') // ===== 2.0 新增:URL 参数预填 + session 恢复 ===== const [searchParams] = useSearchParams() useEffect(() => { const q = searchParams.get('q') if (q) { setQuestion(q) } // 从 URL ?mid= 恢复 matterId(断点续接时用于闭环操作) const mid = searchParams.get('mid') if (mid) { setMatterId(Number(mid)) } }, [searchParams]) /** 从 URL ?sid= 恢复 session 和历史消息(断点续接) */ useEffect(() => { const restoreSid = searchParams.get('sid') if (!restoreSid || sid) return // 已有 session 不重复恢复 setSid(restoreSid) setRestoringHistory(true) // 如果 URL 没有 mid,通过 session_id 查询关联案卡恢复 matterId const mid = searchParams.get('mid') if (!mid) { api.getMatterBySession(restoreSid) .then((m) => setMatterId(m.id)) .catch(() => {/* 没有关联案卡,不影响 */}) } api.getHistoryMessages(restoreSid) .then((res) => { if (res.need_verify) { // 保密模式:需要密码验证,弹出验证框 setRestoringHistory(false) setRestoreVerifyOpen(true) return } if (res.messages && res.messages.length > 0) { restoreMessages(res.messages) } }) .catch(() => { // 历史加载失败(可能 session 已过期),静默处理 }) .finally(() => setRestoringHistory(false)) }, [searchParams, sid]) /** 恢复消息到对话区 */ const restoreMessages = (messages: HistoryMessage[]) => { const restored = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, answer: m.role === 'assistant' ? m.content : undefined, risk: m.risk?.level ? { level: m.risk.level, conclusion: m.risk.conclusion || '', actions: m.risk.actions || [], evidence: m.risk.evidence || [], escalation: m.risk.escalation || false, } : undefined, model: m.meta?.model, inferenceLocation: m.meta?.inference_location, })) setMessages(restored) toast.success('已恢复之前的对话,可继续提问') } /** 保密模式密码验证后恢复 */ const handleRestoreVerify = async () => { if (!restorePassword.trim()) { toast.error('请输入密码') return } const restoreSid = searchParams.get('sid') if (!restoreSid) return setVerifyLoading(true) try { const res = await api.verifyHistory(restoreSid, restorePassword) if (res.messages && res.messages.length > 0) { restoreMessages(res.messages) setRestoreVerifyOpen(false) setRestorePassword('') } else { toast.error('没有历史消息') setRestoreVerifyOpen(false) } } catch { toast.error('密码验证失败') } finally { setVerifyLoading(false) } } // ===== 2.0 新增:location state 传入 expertId ===== const location = useLocation() const expertId = (location.state as any)?.expertId const lastTopic = (location.state as any)?.lastTopic /** 切换话题时重置为内置示例 */ useEffect(() => { setDynamicSamples(samples) }, [samples]) /** 挂载时取当天热点问题,有则存入池并显示首批 */ useEffect(() => { api.getDailyTopics(scene) .then((res) => { if (res.questions?.length > 0) { setDailyPool(res.questions) setPoolIndex(0) setDynamicSamples(res.questions.slice(0, BATCH_SIZE)) } }) .catch(() => {/* 用兜底示例 */}) }, [scene]) const [destroying, setDestroying] = useState(false) /** 恢复历史中(从 ?sid= 断点续接) */ const [restoringHistory, setRestoringHistory] = useState(false) /** 保密模式恢复时密码验证弹窗 */ const [restoreVerifyOpen, setRestoreVerifyOpen] = useState(false) const [restorePassword, setRestorePassword] = useState('') const [verifyLoading, setVerifyLoading] = useState(false) const [confirmDestroy, setConfirmDestroy] = useState(false) /** 专家咨询弹窗 */ const [expertConsultOpen, setExpertConsultOpen] = useState(false) const [expertList, setExpertList] = useState([]) const [selectedExpert, setSelectedExpert] = useState(null) const [consultQuestion, setConsultQuestion] = useState('') const [consultSupplement, setConsultSupplement] = useState('') const [consulting, setConsulting] = useState(false) /** 追问状态 */ const [factQuestions, setFactQuestions] = useState>([]) const [factAnswers, setFactAnswers] = useState>([]) const [factInputs, setFactInputs] = useState>({}) /** 事实确认区:追问完成后展示,允许用户修改再继续 */ const [factConfirm, setFactConfirm] = useState>([]) const [factConfirmEditing, setFactConfirmEditing] = useState>({}) const [profileFacts, setProfileFacts] = useState>([]) const [elementTemplate, setElementTemplate] = useState | null>(null) const [elementFiling, setElementFiling] = useState<{ cause_of_action: string; elements: Array<{ key: string; label: string; value: string }>; claims: string[]; filing_text: string } | null>(null) const [generatingFiling, setGeneratingFiling] = useState(false) const [analyzing, setAnalyzing] = useState(false) const fileRef = useRef(null) /** 待切换的目标模式(销毁确认后切换) */ const [pendingMode, setPendingMode] = useState(null) /** 当前分析中的用户问题(避免从 messages 闭包取旧值) */ const currentQuestionRef = useRef('') /** 验证 session 是否有效,失效则创建新 session */ const ensureSession = async (): Promise => { if (sid) { try { await api.sessionAlive(sid) // session 存活但 matterId 可能丢失(页面刷新后),通过 session_id 恢复 if (!matterId) { api.getMatterBySession(sid) .then(m => setMatterId(m.id)) .catch(() => {/* 没有关联案卡,不影响 */}) } return sid } catch { // session 已失效(410/404,如后端重启后内存 session 丢失),清掉旧 sid setSid('') } } const res = await api.createSession(mode) const newSid = res.session_id setSid(newSid) toast.success(`已创建${modes.find((m) => m.value === mode)?.label}会话`) // 2.0 新增:创建案卡(失败不影响主流程) api.createMatter({ scene, title: currentQuestionRef.current.substring(0, 30) || topic || '新事项', session_id: newSid }) .then(m => setMatterId(m.id)) .catch(() => {/* 案卡创建失败不影响主流程 */}) return newSid } /** 流式输出 AI 分析结论(事实已充分后调用) */ const streamAnalysis = async (currentSid: string, userQuestion: string, answers: Array<{ dim_key: string; dim_desc: string; answer: string }>) => { // 用 ref 保存 assistant 消息 index,避免闭包旧值 const assistantIndexRef = { current: 0 } setMessages((prev) => { assistantIndexRef.current = prev.length return [...prev, { role: 'assistant', content: '', streaming: true }] }) let accumulated = '' let riskCard: AskResponse['risk'] | undefined let doneData: Partial = {} await api.askStream(currentSid, userQuestion, scene, emotionMode, { onRisk: (risk) => { riskCard = risk setMessages((prev) => { const next = [...prev] const idx = assistantIndexRef.current if (next[idx]) { next[idx] = { ...next[idx], risk: showRiskCard ? risk : undefined } } return next }) }, onDelta: (content) => { accumulated += content setMessages((prev) => { const next = [...prev] const idx = assistantIndexRef.current if (next[idx]) { next[idx] = { ...next[idx], content: accumulated } } return next }) }, onDone: (data) => { doneData = data setMessages((prev) => { const next = [...prev] const idx = assistantIndexRef.current if (next[idx]) { next[idx] = { ...next[idx], content: accumulated || doneData.answer || riskCard?.conclusion || '', streaming: false, answer: doneData.answer, model: doneData.model, inferenceLocation: doneData.inference_location, fallbackUsed: doneData.fallback_used, ragContext: doneData.rag_context, } } return next }) }, onError: (error) => { setMessages((prev) => { const next = [...prev] const idx = assistantIndexRef.current if (next[idx]) { next[idx] = { ...next[idx], content: accumulated || `⚠️ ${error}`, streaming: false } } return next }) toast.error(error) }, }, answers.length > 0 ? answers : undefined) } /** 换一批:优先从当天热点池轮换,池用完再调 AI 生成(注入用户画像) */ const handleRefreshSamples = async () => { // 池里还有未展示的,直接取下一批 const nextIndex = poolIndex + BATCH_SIZE if (nextIndex < dailyPool.length) { setPoolIndex(nextIndex) setDynamicSamples(dailyPool.slice(nextIndex, nextIndex + BATCH_SIZE)) return } // 池用完,调 AI 生成新问题 setRefreshing(true) try { const phone = getPhone() || undefined const res = await api.generateSamples(scene, topic, dynamicSamples, phone) if (res.samples?.length > 0) { // AI 生成的新问题加入池并显示 const newPool = [...dailyPool, ...res.samples] const newIndex = dailyPool.length setDailyPool(newPool) setPoolIndex(newIndex) setDynamicSamples(res.samples) } } catch { toast.error('换一批失败,请稍后重试') } finally { setRefreshing(false) } } const handleAsk = async () => { if (!question.trim()) { toast.error('请输入问题') return } const userQuestion = question.trim() setLoading(true) setQuestion('') setFactAnswers([]) setFactQuestions([]) setFactInputs({}) setFactConfirm([]) setProfileFacts([]) currentQuestionRef.current = userQuestion // 添加用户消息 setMessages((prev) => [...prev, { role: 'user', content: userQuestion }]) try { const currentSid = await ensureSession() // 第1步:分析问题,判断是否需要追问 setAnalyzing(true) const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode, undefined, topic) setAnalyzing(false) // 保存画像带入事实 const pf = analysis.profile_facts || [] setProfileFacts(pf) setElementTemplate(analysis.element_template || null) if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) { // 需要追问:显示追问问题 setFactQuestions(analysis.questions) // 2.0 新增:更新案卡状态为 asking_facts if (matterId) { try { await api.updateMatterStatus(matterId, 'asking_facts') } catch {/* 失败不影响主流程 */} } // 不继续流式输出,等待用户回答 return } // 不需要追问,进入确认区(带入内容 + 无追问内容) setFactConfirm(pf) setFactConfirmEditing(Object.fromEntries(pf.map((f) => [f.dim_key, f.answer]))) } catch (e) { setAnalyzing(false) toast.error(e instanceof Error ? e.message : '提问失败') } finally { setLoading(false) } } /** 用户提交追问回答 */ const handleFactSubmit = async () => { // 收集回答 const newAnswers = factQuestions .map((q) => ({ dim_key: q.dim_key, dim_desc: q.dim_desc, answer: factInputs[q.dim_key]?.trim() || '', })) .filter((a) => a.answer) if (newAnswers.length === 0) { toast.error('请至少回答一个问题') return } const allAnswers = [...factAnswers, ...newAnswers] setFactAnswers(allAnswers) setLoading(true) // 添加用户回答到消息列表 const answerText = newAnswers.map((a) => `**${a.dim_desc}**: ${a.answer}`).join('\n') setMessages((prev) => [...prev, { role: 'user', content: answerText, isFactAnswer: true }]) try { const currentSid = sid || (await ensureSession()) setAnalyzing(true) const analysis = await api.askAnalyze( currentSid, currentQuestionRef.current, scene, emotionMode, allAnswers, topic, ) setAnalyzing(false) // 更新画像带入事实 const pf = analysis.profile_facts || [] setProfileFacts(pf) setElementTemplate(analysis.element_template || null) if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) { // 还需追问 setFactQuestions(analysis.questions) setFactInputs({}) return } // 2.0 新增:事实提交后返回 pending_confirm,更新案卡状态 if (matterId) { try { await api.updateMatterStatus(matterId, 'pending_confirm') } catch {/* 失败不影响主流程 */} } // 事实充分,进入确认区(带入内容 + 追问内容) setFactQuestions([]) setFactInputs({}) // 合并:画像带入事实在前,追问回答在后(去重 by dim_key) const mergedMap = new Map() for (const f of pf) mergedMap.set(f.dim_key, f) for (const a of allAnswers) mergedMap.set(a.dim_key, a) const merged = Array.from(mergedMap.values()) setFactConfirm(merged) setFactConfirmEditing(Object.fromEntries(merged.map((a) => [a.dim_key, a.answer]))) } catch (e) { setAnalyzing(false) toast.error(e instanceof Error ? e.message : '追问失败') } finally { setLoading(false) } } const handleDestroy = async () => { if (!sid) return setDestroying(true) try { await api.destroy(sid) setSid(null) setMatterId(null) setMessages([]) setFactAnswers([]) setFactQuestions([]) setFactInputs({}) // 如果是模式切换触发的销毁,切换到目标模式 if (pendingMode) { setMode(pendingMode) setPendingMode(null) toast.success(`已切换到${modes.find((m) => m.value === pendingMode)?.label}模式`) } else { toast.success('会话已销毁,所有内容已清除') } } catch (e) { toast.error(e instanceof Error ? e.message : '销毁失败') } finally { setDestroying(false) setConfirmDestroy(false) } } /** 触发问专家:提取原始问题和追问补充,打开专家咨询弹窗 */ const handleEscalate = () => { const originalQuestion = messages.find((m) => m.role === 'user' && !m.isFactAnswer) const factAnswers = messages.filter((m) => m.isFactAnswer) const supplement = factAnswers .map((m) => m.content.replace(/\*\*/g, '')) .join('\n') openExpertConsult(originalQuestion?.content || '', supplement) } /** 打开专家咨询弹窗:加载该场景分类的专家 */ const openExpertConsult = async (topic: string, supplement: string) => { setConsultQuestion(topic) setConsultSupplement(supplement) setExpertConsultOpen(true) setSelectedExpert(null) try { // 只加载已确认服务(active)的专家 const res = await api.myServices() const activeServices = res.results.filter(s => s.status === 'active') setExpertList(activeServices) // 2.0 新增:如果有 location state 传入 expertId,默认选中 if (expertId) { const matched = activeServices.find(s => s.expert_id === expertId) if (matched) setSelectedExpert(matched) } } catch { toast.error('加载服务专家失败') setExpertList([]) } } /** 发送咨询给专家 */ const handleConsult = async () => { if (!selectedExpert) { toast.error('请选择一位专家') return } setConsulting(true) try { // 获取最近的 AI 回复作为上下文 const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant' && m.answer) const context = lastAssistant?.answer || '' // 咨询内容 = 问题 + 补充内容(直接换行拼接) const fullQuestion = consultSupplement.trim() ? `${consultQuestion}\n\n${consultSupplement.trim()}` : consultQuestion await api.createConsultation({ expert_id: selectedExpert.expert_id, topic: consultQuestion.slice(0, 200), context: context.slice(0, 500), question: fullQuestion, }) toast.success(`已发送给${selectedExpert.expert_name},请到专家库查看回复`) // 2.0 新增:问专家后更新案卡状态为 pending_expert if (matterId) { try { await api.updateMatterStatus(matterId, 'pending_expert') } catch {/* 失败不影响主流程 */} } setExpertConsultOpen(false) setSelectedExpert(null) setConsultQuestion('') setConsultSupplement('') } catch (e) { toast.error(e instanceof Error ? e.message : '发送失败,请先选定专家') } finally { setConsulting(false) } } const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file || !sid) { if (!sid) toast.error('请先创建会话再上传文件') return } try { const res = await api.uploadFile(sid, file) toast.success(`文件已进入临时会话区: ${res.filename}`) setMessages((prev) => [ ...prev, { role: 'user', content: `[上传文件] ${res.filename}` }, { role: 'assistant', content: res.message + (res.text_preview ? `\n\n预览:\n${res.text_preview}` : '') }, ]) } catch (err) { toast.error(err instanceof Error ? err.message : '上传失败') } finally { if (fileRef.current) fileRef.current.value = '' } } const switchMode = (m: SessionMode) => { if (sid) { setPendingMode(m) setConfirmDestroy(true) return } setMode(m) } /** 2.0 新增:闭环案卡 */ const handleCloseMatter = async () => { if (!matterId) return setClosing(true) try { await api.closeMatter(matterId, closeResult, closeNote) toast.success('事项已闭环') setShowCloseDialog(false) setMatterId(null) setCloseNote('') setCloseResult('顺利') } catch (e) { toast.error(e instanceof Error ? e.message : '闭环失败') } finally { setClosing(false) } } /** 2.0 新增:分享风险卡(复制到剪贴板) */ const copyRisk = () => { const topMsg = messages.find(m => m.risk) if (!topMsg?.risk) return const text = riskCardToMarkdown(topMsg.risk, topMsg.answer) navigator.clipboard.writeText(text).then(() => { toast.success('风险卡已复制到剪贴板') }).catch(() => { toast.error('复制失败') }) } /** 2.0 新增:导出风险卡(下载 Markdown) */ const exportRisk = () => { const topMsg = messages.find(m => m.risk) if (!topMsg?.risk) return const text = riskCardToMarkdown(topMsg.risk, topMsg.answer) const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `风险结论_${Date.now()}.md` a.click() URL.revokeObjectURL(url) toast.success('已导出 Markdown 文件') } /** 2.0 新增:打开起诉状三步向导 */ const openFilingWizard = async () => { const confirmedAnswers = factConfirm.map((f) => ({ dim_key: f.dim_key, dim_desc: f.dim_desc, answer: (factConfirmEditing[f.dim_key] ?? '').trim() || f.answer, })) setGeneratingFiling(true) try { const currentSid = sid || (await ensureSession()) const causeOfAction = (elementTemplate as Record)?.cause_of_action as string || '劳动争议纠纷' const res = await api.elementFiling(currentSid, currentQuestionRef.current, causeOfAction, confirmedAnswers, elementTemplate || undefined) setFilingCause(res.cause_of_action) setFilingElements(res.elements) setFilingClaims(res.claims) setFilingText(res.filing_text) setFilingStep(1) setFilingWizardOpen(true) } catch (e) { toast.error(e instanceof Error ? e.message : '要素式起诉状生成失败') } finally { setGeneratingFiling(false) } } /** 2.0 新增:起诉状向导-导出/发送/复制 */ const handleFilingExport = () => { const blob = new Blob([filingText], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `起诉状_${filingCause}_${Date.now()}.md` a.click() URL.revokeObjectURL(url) toast.success('起诉状已导出') } const handleFilingSendToExpert = () => { setFilingWizardOpen(false) openExpertConsult(`要素式起诉状·${filingCause}`, filingText) } const handleFilingCopy = () => { navigator.clipboard.writeText(filingText).then(() => { toast.success('已复制到剪贴板') }).catch(() => { toast.error('复制失败') }) } const currentMode = modes.find((m) => m.value === mode)! // 2.0 新增:风险卡置顶 - 取第一条带 risk 的消息 const topRiskMsg = messages.find(m => m.risk && !m.streaming) const topRiskCard = topRiskMsg?.risk const analysisComplete = !!topRiskMsg && !topRiskMsg.streaming return ( <>
对话 {/* 模式选择:紧凑 segmented control */}
{modes.map((m) => ( ))}
{pendingMode ? `切换到${modes.find((m) => m.value === pendingMode)?.label}模式?` : '确认销毁当前会话?'} {pendingMode ? `切换模式将销毁当前会话,切换到${modes.find((m) => m.value === pendingMode)?.label}模式。` : '销毁后将立即清除会话密钥和所有消息内容,不可恢复。' } 若涉及正式合同、诉讼或劳动争议,可能需要依法留档。
{/* 场景特定选择器(如情绪模式) */} {modeSelector} {/* 2.0 新增:location state 传入 expertId 时显示提示 */} {expertId && (
咨询{selectedExpert?.expert_name || '专家'}{lastTopic ? `·上次话题:${lastTopic}` : ''}
)} {sid && (
{currentMode.label}会话已建立
)} {/* 2.0 新增:风险卡置顶(sticky,仅显示结论摘要 + 分享/导出) */} {topRiskCard && (
风险结论 {topRiskCard.conclusion}
)} {/* 对话区(可滚动,输入区固定在下方) */} {messages.length === 0 ? (
{dynamicSamples.map((s) => ( ))}
) : (
{messages.map((msg, i) => (
{msg.risk ? ( ) : msg.role === 'user' && !msg.isFactAnswer ? (

{msg.content}

) : msg.isFactAnswer ? ( ) : (
{/* 参考来源 tag + 问专家按钮(流式完成后显示) */} {!msg.streaming && ( <>
参考来源: {msg.ragContext && msg.ragContext.length > 0 ? ( <> {msg.ragContext.map((doc, i) => ( {doc.title} {doc.doc_type && ( {DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type} )} {doc.region && ( {doc.region} )} ))} ) : ( <> AI 提供 )}
)}
)}
))}
)} {/* 事实追问区 */} {factQuestions.length > 0 && (
请补充以下信息,以便更准确地分析
{factQuestions.map((q) => (
setFactInputs((prev) => ({ ...prev, [q.dim_key]: e.target.value }))} placeholder="请输入…" className="bg-white" />
))}
)} {/* 确认事实区:带入内容 + 追问内容,允许用户修改再继续 */} {factConfirm.length > 0 && (
确认事实

请核对以下信息,可直接修改后继续分析。确认后将更新你的用户画像。

{factConfirm.map((f) => { const isProfile = profileFacts.some((p) => p.dim_key === f.dim_key) const rawValue = factConfirmEditing[f.dim_key] ?? f.answer const displayValue = isProfile ? translateProfileValue(rawValue) : rawValue return (
{translateDimLabel(f.dim_desc)} setFactConfirmEditing((prev) => ({ ...prev, [f.dim_key]: e.target.value }))} className="min-w-0 flex-1 border-none bg-transparent px-0.5 py-0 text-xs text-foreground focus:outline-none" />
) })}
{scene === 'legal' && elementTemplate && ( )}
)} {/* 要素化立案结果(兼容旧版直接展示) */} {elementFiling && !filingWizardOpen && (
要素式起诉状 · {elementFiling.cause_of_action}

诉讼请求

{elementFiling.claims.map((c, i) => ( ☐ {c} ))}

要素清单

{elementFiling.elements.map((e, i) => (
{e.label} {e.value}
))}
查看完整起诉状文本
{elementFiling.filing_text}
)} {/* 分析中提示 */} {analyzing && (
正在分析问题…
)} {/* 恢复历史中提示 */} {restoringHistory && (
正在恢复之前的对话…
)} {/* 输入区 */}