diff --git a/src/index.css b/src/index.css index 38ad15a..5590b16 100644 --- a/src/index.css +++ b/src/index.css @@ -204,6 +204,15 @@ .print-content .smart-field-wrapper .delete-btn { display: none !important; } + .print-content .ai-region { + border: none !important; + background: transparent !important; + padding: 0 !important; + margin: 0 !important; + } + .print-content .ai-region > [contenteditable="false"] { + display: none !important; + } .report-signature-img { max-width: 120px !important; max-height: 40px !important; diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index f9f57f0..c1cf1c3 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -5,7 +5,8 @@ import Sidebar from '../components/Sidebar'; import { Check, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, - Video, Play, Pause, Plus, X, ChevronLeft, Download + Video, Play, Pause, Plus, X, ChevronLeft, Download, + Bot, Mic, MicOff, ImagePlus, Sparkles, Send } from 'lucide-react'; import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types'; import { defaultReportContent } from '../utils/defaultContent'; @@ -53,9 +54,25 @@ export default function ReportEditor() { const [pendingTemplateId, setPendingTemplateId] = useState(null); const prevVideoCountRef = useRef(0); - const [activeTab, setActiveTab] = useState<'info' | 'video'>('info'); + const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info'); const [activeFieldKey, setActiveFieldKey] = useState(null); + // AI 撰写相关核心状态 + const [chatInput, setChatInput] = useState(''); + const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]); + const [isGenerating, setIsGenerating] = useState(false); + const [aiSelectedFrames, setAiSelectedFrames] = useState([]); + const [aiTargetRegion, setAiTargetRegion] = useState('none'); + const [aiModifyEnabled, setAiModifyEnabled] = useState(true); + const [isListening, setIsListening] = useState(false); + const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]); + const speechRecognitionRef = useRef(null); + const [quickPrompts, setQuickPrompts] = useState([ + '请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结', '根据截图描述游离过程' + ]); + const [isEditingPrompts, setIsEditingPrompts] = useState(false); + const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null); + useEffect(() => { if (!editorRef.current) return; const allFields = editorRef.current.querySelectorAll('.field-value'); @@ -604,6 +621,20 @@ export default function ReportEditor() { setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' }); }; + const insertAiRegion = () => { + const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):'); + if (!name || !name.trim()) return; + if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) { + window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。'); + return; + } + editorRef.current?.focus(); + const html = `
${name}-AI可编辑区域


`; + document.execCommand('insertHTML', false, html); + if (editorRef.current) contentRef.current = editorRef.current.innerHTML; + saveDraftToStorage(); + }; + const handleVideoUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) as File[]; const newVideos = files.map(file => ({ @@ -767,6 +798,147 @@ export default function ReportEditor() { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; + const checkAiRegions = () => { + if (!editorRef.current) return []; + return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => { + const id = (el as HTMLElement).getAttribute('data-ai-id') || ''; + const title = (el as HTMLElement).getAttribute('data-ai-title') || id; + return { id, title }; + }).filter(r => r.id); + }; + + const toggleListening = () => { + if (isListening) { + setIsListening(false); + if (speechRecognitionRef.current) speechRecognitionRef.current.stop(); + } else { + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) { + alert('您的浏览器不支持原生语音识别,请使用 Chrome。'); + return; + } + const recognition = new SpeechRecognition(); + recognition.lang = 'zh-CN'; + recognition.continuous = true; + recognition.interimResults = true; + let finalTranscript = chatInput; + recognition.onstart = () => setIsListening(true); + recognition.onresult = (event: any) => { + let interimTranscript = ''; + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + finalTranscript += event.results[i][0].transcript; + } else { + interimTranscript += event.results[i][0].transcript; + } + } + setChatInput(finalTranscript + interimTranscript); + }; + recognition.onerror = () => setIsListening(false); + recognition.onend = () => setIsListening(false); + speechRecognitionRef.current = recognition; + recognition.start(); + } + }; + + const handleAIGenerate = async (text: string) => { + if (!text.trim()) return; + const userMsgId = Date.now().toString(); + setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text }]); + setChatInput(''); + setIsGenerating(true); + try { + const settings = storage.get('systemSettings', {} as SystemSettings); + const apiKey = settings.kimiApiKey || ''; + const apiEndpoint = settings.kimiApiEndpoint || 'https://api.moonshot.cn/v1'; + if (!apiKey) { + setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 Kimi API Key,请前往系统设置填写。' }]); + setIsGenerating(false); + return; + } + const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null; + const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : ''; + const messageContent: any[] = []; + const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean); + const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)]; + allImages.forEach(url => { + messageContent.push({ type: 'image_url', image_url: { url } }); + }); + let promptText = `【医生指令】: ${text}`; + if (aiModifyEnabled && targetRegionEl) { + promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`; + } + messageContent.push({ type: 'text', text: promptText }); + const systemPrompt = aiModifyEnabled && targetRegionEl + ? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }' + : '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }'; + const response = await fetch(`${apiEndpoint}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'kimi-k2-5', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: messageContent } + ], + temperature: 0.3 + }) + }); + if (!response.ok) throw new Error(`API 请求失败: ${response.status}`); + const data = await response.json(); + const responseText = data.choices[0].message.content.trim(); + const cleanedText = responseText.replace(/```json\n?|```/g, ''); + let responseJson: any = {}; + try { + responseJson = JSON.parse(cleanedText); + } catch { + const jsonMatch = cleanedText.match(/\{[\s\S]*\}/); + if (jsonMatch) responseJson = JSON.parse(jsonMatch[0]); + else throw new Error('AI 返回格式异常,无法解析 JSON'); + } + if (responseJson.reply) { + setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]); + } + if (responseJson.updatedHtml && aiModifyEnabled && targetRegionEl) { + setDiffModal({ + isOpen: true, + originalHtml: currentHtml, + newHtml: responseJson.updatedHtml, + targetId: aiTargetRegion + }); + } + setAiUploadedImages([]); + setAiSelectedFrames([]); + } catch (error: any) { + console.error(error); + setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]); + } finally { + setIsGenerating(false); + } + }; + + const confirmAiInjection = (newHtml: string, regionId: string) => { + if (!editorRef.current) return; + const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement; + if (targetContent) { + targetContent.innerHTML = newHtml; + targetContent.style.transition = 'background-color 0.3s ease'; + targetContent.style.backgroundColor = '#bfdbfe'; + setTimeout(() => { + targetContent.style.backgroundColor = '#eff6ff'; + setTimeout(() => { + targetContent.style.backgroundColor = 'transparent'; + }, 800); + }, 400); + contentRef.current = editorRef.current.innerHTML; + saveDraftToStorage(); + } + setDiffModal(null); + }; + const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => { e.dataTransfer.setData('frameId', frame.id.toString()); }; @@ -1482,6 +1654,7 @@ export default function ReportEditor() {