Files
insar-management-system-v2/frontend/src/AiAnalysisPanel.jsx
T

651 lines
25 KiB
React

import { useCallback, useEffect, useState } from 'react';
import { useI18n } from './i18n/I18nContext';
import {
getPromptTemplates,
createDiagnosis,
listDiagnoses,
deleteDiagnosis,
getAiStatus,
} from './api/ai';
import { getDinsarResults } from './api/dinsar';
import AiDiagnosisModal from './components/AiDiagnosisModal';
import TaskStatusPanel from './components/tasks/TaskStatusPanel';
import useTaskMonitor from './hooks/useTaskMonitor';
const cardStyle = {
background: '#fff',
padding: '12px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
marginBottom: '12px',
};
export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
const { en } = useI18n();
const aiTaskMonitor = useTaskMonitor({
taskTypes: ['AI_DIAGNOSIS'],
showRecent: true,
recentLimit: 1,
});
// 状态
const [aiStatus, setAiStatus] = useState(null);
const [promptTemplates, setPromptTemplates] = useState({});
const [dinsarResults, setDinsarResults] = useState([]);
const [diagnoses, setDiagnoses] = useState([]);
const [totalDiagnoses, setTotalDiagnoses] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
// 表单状态
const [selectedResultId, setSelectedResultId] = useState('');
const [selectedModel, setSelectedModel] = useState('llama3.2-vision');
const [selectedTemplate, setSelectedTemplate] = useState('standard');
const [customPrompt, setCustomPrompt] = useState('');
const [useCustomPrompt, setUseCustomPrompt] = useState(false);
// 过滤器
const [filterResultId, setFilterResultId] = useState('');
const [filterRiskLevel, setFilterRiskLevel] = useState('');
// Modal
const [selectedDiagnosis, setSelectedDiagnosis] = useState(null);
// 加载初始数据
const loadInitialData = useCallback(async () => {
try {
const [status, templates, results] = await Promise.all([
getAiStatus(),
getPromptTemplates(),
getDinsarResults({ limit: 100, offset: 0 }),
]);
setAiStatus(status);
setPromptTemplates(templates);
// 兼容不同的响应格式
const resultsList = results.results || results.items || [];
setDinsarResults(resultsList);
if (resultsList.length === 0) {
setMessage(en ? 'No D-InSAR results found. Please scan data first.' : '未找到 D-InSAR 结果,请先扫描数据。');
}
} catch (error) {
console.error('Failed to load initial data:', error);
setMessage(en ? `Failed to load data: ${error.message}` : `加载数据失败: ${error.message}`);
}
}, [en]);
useEffect(() => {
loadInitialData();
}, [loadInitialData]);
useEffect(() => {
const models = aiStatus?.ollama_vlm_models || [];
if (models.length > 0 && !models.includes(selectedModel)) {
setSelectedModel(aiStatus?.default_vlm_model && models.includes(aiStatus.default_vlm_model)
? aiStatus.default_vlm_model
: models[0]);
}
}, [aiStatus, selectedModel]);
// 加载诊断列表
const loadDiagnoses = useCallback(async () => {
setLoading(true);
try {
const params = {
page: currentPage,
page_size: pageSize,
};
if (filterResultId) params.result_id = parseInt(filterResultId);
if (filterRiskLevel) params.risk_level = filterRiskLevel;
const data = await listDiagnoses(params);
setDiagnoses(data.items || []);
setTotalDiagnoses(data.total || 0);
} catch (error) {
console.error('Failed to load diagnoses:', error);
setMessage(en ? 'Failed to load diagnoses' : '加载诊断列表失败');
} finally {
setLoading(false);
}
}, [currentPage, pageSize, filterResultId, filterRiskLevel, en]);
useEffect(() => {
loadDiagnoses();
}, [loadDiagnoses]);
// 创建诊断
const handleCreateDiagnosis = async () => {
if (readOnly) {
setMessage(en ? 'Read-only account cannot create diagnosis' : '只读账号无法创建诊断');
return;
}
if (!selectedResultId) {
setMessage(en ? 'Please select a D-InSAR result' : '请选择 D-InSAR 结果');
return;
}
if (!aiStatus?.ollama_online) {
setMessage(en ? 'Failed: Ollama is offline' : '失败: Ollama 未在线');
return;
}
if (!aiStatus?.ollama_vlm_models?.length) {
setMessage(en ? 'Failed: no local Ollama vision model is installed' : '失败: 未检测到本机 Ollama 视觉模型');
return;
}
setLoading(true);
setMessage('');
try {
const payload = {
result_id: parseInt(selectedResultId),
model_name: selectedModel,
prompt_template: selectedTemplate,
};
if (useCustomPrompt && customPrompt.trim()) {
payload.custom_prompt = customPrompt.trim();
}
const diagnosis = await createDiagnosis(payload);
setMessage(en ? 'Diagnosis task created' : '诊断任务已创建');
if (diagnosis.task_id) {
onJobQueued?.(diagnosis.task_id);
}
// 刷新列表
setTimeout(() => loadDiagnoses(), 1000);
} catch (error) {
const detail = error?.response?.data?.detail || error?.message || (en ? 'Unknown error' : '未知错误');
setMessage(`${en ? 'Failed' : '失败'}: ${detail}`);
} finally {
setLoading(false);
}
};
// 删除诊断
const handleDeleteDiagnosis = async (diagnosisId) => {
if (readOnly) {
setMessage(en ? 'Read-only account cannot delete' : '只读账号无法删除');
return;
}
if (!confirm(en ? 'Delete this diagnosis?' : '确认删除此诊断记录?')) return;
try {
await deleteDiagnosis(diagnosisId);
setMessage(en ? 'Diagnosis deleted' : '诊断记录已删除');
loadDiagnoses();
} catch {
setMessage(en ? 'Failed to delete' : '删除失败');
}
};
// 查看诊断详情
const handleViewDiagnosis = (diagnosis) => {
setSelectedDiagnosis(diagnosis);
};
const riskLevelColors = {
low: '#48bb78',
medium: '#ed8936',
high: '#f56565',
critical: '#c53030',
};
const ollamaVlmModels = aiStatus?.ollama_vlm_models || [];
const modelOptions = ollamaVlmModels.length > 0
? ollamaVlmModels
: [aiStatus?.default_vlm_model || selectedModel].filter(Boolean);
const canCreateDiagnosis = !!selectedResultId && !!aiStatus?.ollama_online && ollamaVlmModels.length > 0;
const totalPages = Math.ceil(totalDiagnoses / pageSize);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>
{en ? 'D-InSAR Diagnosis' : 'D-InSAR诊断'}
</h2>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '12px' }}>
{/* Ollama Status */}
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '16px', fontWeight: 600 }}>
{en ? 'Ollama Status' : 'Ollama 状态'}
</span>
<span
style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: aiStatus?.ollama_online ? '#48bb78' : '#f56565',
}}
/>
</div>
{aiStatus && (
<div style={{ fontSize: '13px', color: '#718096' }}>
{en ? 'Online' : '在线'}: {aiStatus.ollama_online ? (en ? 'Yes' : '是') : (en ? 'No' : '否')}
</div>
)}
</div>
{/* Create Diagnosis Form */}
<div style={cardStyle}>
<h3 style={{ margin: '0 0 12px 0', fontSize: '15px', fontWeight: 600 }}>
{en ? 'Create Diagnosis' : '创建诊断'}
</h3>
<TaskStatusPanel
title={en ? 'D-InSAR Diagnosis Task' : 'D-InSAR诊断任务'}
activeTasks={aiTaskMonitor.activeTasks}
recentTasks={aiTaskMonitor.recentTasks}
latestTask={aiTaskMonitor.latestTask}
isBusy={aiTaskMonitor.isBusy}
idleText={en ? 'No D-InSAR diagnosis task is running.' : '当前没有正在执行的 D-InSAR 诊断任务。'}
compact
/>
{/* D-InSAR Result Selection */}
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<label style={{ fontSize: '13px', color: '#4a5568' }}>
{en ? 'D-InSAR Result' : 'D-InSAR 结果'}
</label>
<button
onClick={loadInitialData}
style={{
padding: '2px 8px',
fontSize: '12px',
backgroundColor: '#edf2f7',
border: '1px solid #cbd5e0',
borderRadius: '4px',
cursor: 'pointer',
}}
>
🔄 {en ? 'Refresh' : '刷新'}
</button>
</div>
<select
value={selectedResultId}
onChange={(e) => setSelectedResultId(e.target.value)}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
}}
>
<option value="">
{dinsarResults.length === 0
? (en ? 'No results available (scan data first)' : '无可用结果(请先扫描数据)')
: (en ? 'Select a result...' : '选择结果...')}
</option>
{dinsarResults.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
{dinsarResults.length === 0 && (
<div style={{ marginTop: '6px', fontSize: '12px', color: '#e53e3e' }}>
{en
? 'No D-InSAR results found. Please go to "D-InSAR Results" tab and scan data first.'
: '未找到 D-InSAR 结果。请前往"D-InSAR 结果"选项卡先扫描数据。'}
</div>
)}
</div>
{/* Model Selection */}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: '#4a5568' }}>
{en ? 'Model' : '模型'}
</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
}}
>
{modelOptions.map((modelName) => (
<option key={modelName} value={modelName}>{modelName}</option>
))}
</select>
{aiStatus?.ollama_online && ollamaVlmModels.length === 0 && (
<div style={{ marginTop: '6px', fontSize: '12px', color: '#e53e3e' }}>
{en ? 'Ollama is online, but no local vision model is installed.' : 'Ollama 已在线,但未检测到本机视觉模型。'}
</div>
)}
</div>
{/* Prompt Template Selection */}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: '#4a5568' }}>
{en ? 'Prompt Template' : 'Prompt 模板'}
</label>
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
disabled={useCustomPrompt}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
opacity: useCustomPrompt ? 0.5 : 1,
}}
>
{Object.entries(promptTemplates).map(([key, value]) => (
<option key={key} value={key}>
{key} - {value.description}
</option>
))}
</select>
</div>
{/* Custom Prompt Toggle */}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', fontSize: '13px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={useCustomPrompt}
onChange={(e) => setUseCustomPrompt(e.target.checked)}
style={{ marginRight: '6px' }}
/>
{en ? 'Use custom prompt' : '使用自定义 Prompt'}
</label>
</div>
{/* Custom Prompt Textarea */}
{useCustomPrompt && (
<div style={{ marginBottom: '12px' }}>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder={en ? 'Enter custom prompt...' : '输入自定义 Prompt...'}
style={{
width: '100%',
minHeight: '100px',
padding: '8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'vertical',
}}
/>
</div>
)}
{/* Submit Button */}
<button
onClick={handleCreateDiagnosis}
disabled={loading || aiTaskMonitor.isBusy || !canCreateDiagnosis}
style={{
width: '100%',
padding: '8px',
backgroundColor: loading || aiTaskMonitor.isBusy || !canCreateDiagnosis ? '#cbd5e0' : '#3182ce',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 500,
cursor: loading || aiTaskMonitor.isBusy || !canCreateDiagnosis ? 'not-allowed' : 'pointer',
}}
>
{loading || aiTaskMonitor.isBusy ? (en ? 'Creating...' : '创建中...') : (en ? 'Create Diagnosis' : '创建诊断')}
</button>
{/* Message */}
{message && (
<div
style={{
marginTop: '12px',
padding: '8px',
backgroundColor: message.includes('失败') || message.includes('Failed') ? '#fed7d7' : '#c6f6d5',
color: message.includes('失败') || message.includes('Failed') ? '#c53030' : '#22543d',
borderRadius: '4px',
fontSize: '13px',
}}
>
{message}
</div>
)}
</div>
{/* Filters */}
<div style={cardStyle}>
<h3 style={{ margin: '0 0 12px 0', fontSize: '15px', fontWeight: 600 }}>
{en ? 'Filters' : '过滤器'}
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: '#4a5568' }}>
{en ? 'Result ID' : '结果 ID'}
</label>
<input
type="number"
value={filterResultId}
onChange={(e) => setFilterResultId(e.target.value)}
placeholder={en ? 'Filter by result ID' : '按结果 ID 过滤'}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: '#4a5568' }}>
{en ? 'Risk Level' : '风险等级'}
</label>
<select
value={filterRiskLevel}
onChange={(e) => setFilterRiskLevel(e.target.value)}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
}}
>
<option value="">{en ? 'All' : '全部'}</option>
<option value="low">{en ? 'Low' : '低'}</option>
<option value="medium">{en ? 'Medium' : '中'}</option>
<option value="high">{en ? 'High' : '高'}</option>
<option value="critical">{en ? 'Critical' : '极高'}</option>
</select>
</div>
</div>
</div>
{/* Diagnoses Table */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>
{en ? 'Diagnosis Records' : '诊断记录'} ({totalDiagnoses})
</h3>
<button
onClick={loadDiagnoses}
disabled={loading}
style={{
padding: '4px 12px',
backgroundColor: '#edf2f7',
border: '1px solid #cbd5e0',
borderRadius: '4px',
fontSize: '13px',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '⟳' : '🔄'} {en ? 'Refresh' : '刷新'}
</button>
</div>
{diagnoses.length === 0 ? (
<div style={{ textAlign: 'center', padding: '24px', color: '#a0aec0', fontSize: '14px' }}>
{en ? 'No diagnosis records' : '暂无诊断记录'}
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', fontSize: '13px', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#f7fafc', borderBottom: '2px solid #e2e8f0' }}>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>ID</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Result' : '结果名称'}
</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Model' : '模型'}
</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Template' : '模板'}
</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Risk' : '风险'}
</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Hazards' : '隐患点'}
</th>
<th style={{ padding: '8px', textAlign: 'left', fontWeight: 600 }}>
{en ? 'Created' : '创建时间'}
</th>
<th style={{ padding: '8px', textAlign: 'center', fontWeight: 600 }}>
{en ? 'Actions' : '操作'}
</th>
</tr>
</thead>
<tbody>
{diagnoses.map((d) => (
<tr key={d.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '8px' }}>{d.id}</td>
<td style={{ padding: '8px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.result_name || 'N/A'}
</td>
<td style={{ padding: '8px' }}>{d.model_name}</td>
<td style={{ padding: '8px' }}>{d.prompt_template}</td>
<td style={{ padding: '8px' }}>
{d.risk_level ? (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
color: '#fff',
backgroundColor: riskLevelColors[d.risk_level] || '#a0aec0',
}}
>
{d.risk_level}
</span>
) : (
<span style={{ color: '#a0aec0' }}>-</span>
)}
</td>
<td style={{ padding: '8px' }}>{d.hazards_found}</td>
<td style={{ padding: '8px', fontSize: '12px', color: '#718096' }}>
{new Date(d.created_at).toLocaleString()}
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<button
onClick={() => handleViewDiagnosis(d)}
style={{
padding: '4px 8px',
marginRight: '4px',
backgroundColor: '#3182ce',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
}}
>
{en ? 'View' : '查看'}
</button>
<button
onClick={() => handleDeleteDiagnosis(d.id)}
disabled={readOnly}
style={{
padding: '4px 8px',
backgroundColor: readOnly ? '#cbd5e0' : '#f56565',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: readOnly ? 'not-allowed' : 'pointer',
}}
>
{en ? 'Delete' : '删除'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px', marginTop: '12px' }}>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
style={{
padding: '4px 12px',
backgroundColor: currentPage === 1 ? '#edf2f7' : '#3182ce',
color: currentPage === 1 ? '#a0aec0' : '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '13px',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
}}
>
{en ? 'Prev' : '上一页'}
</button>
<span style={{ fontSize: '13px', color: '#4a5568' }}>
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
style={{
padding: '4px 12px',
backgroundColor: currentPage === totalPages ? '#edf2f7' : '#3182ce',
color: currentPage === totalPages ? '#a0aec0' : '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '13px',
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
}}
>
{en ? 'Next' : '下一页'}
</button>
</div>
)}
</div>
</div>
{/* Diagnosis Modal */}
{selectedDiagnosis && (
<AiDiagnosisModal
diagnosis={selectedDiagnosis}
onClose={() => setSelectedDiagnosis(null)}
/>
)}
</div>
);
}