@omniradiology/omnirad 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Textarea, Badge } from "@/components/ui/basic"
|
|
5
|
+
import { MessageSquare, Send, Clock, User as UserIcon, Shield } from "lucide-react"
|
|
6
|
+
import { Comment, AuditLog } from "@/types"
|
|
7
|
+
|
|
8
|
+
interface CollaborationPanelProps {
|
|
9
|
+
comments: Comment[];
|
|
10
|
+
logs: AuditLog[];
|
|
11
|
+
onAddComment: (text: string) => void;
|
|
12
|
+
currentUser: { name: string, role: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CollaborationPanel({ comments, logs, onAddComment, currentUser }: CollaborationPanelProps) {
|
|
16
|
+
const [activeTab, setActiveTab] = React.useState<'comments' | 'logs'>('comments');
|
|
17
|
+
const [newComment, setNewComment] = React.useState("");
|
|
18
|
+
|
|
19
|
+
const handleSubmit = () => {
|
|
20
|
+
if (!newComment.trim()) return;
|
|
21
|
+
onAddComment(newComment);
|
|
22
|
+
setNewComment("");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Card className="h-full flex flex-col bg-bg-panel border-l border-border-primary rounded-none border-y-0 border-r-0">
|
|
27
|
+
<CardHeader className="p-4 border-b border-border-primary bg-bg-surface">
|
|
28
|
+
<div className="flex items-center justify-between mb-4">
|
|
29
|
+
<CardTitle className="text-sm uppercase text-text-muted">Collaboration</CardTitle>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="flex gap-2 bg-bg-panel p-1 rounded-md">
|
|
32
|
+
<button
|
|
33
|
+
onClick={() => setActiveTab('comments')}
|
|
34
|
+
className={`flex-1 text-xs font-medium py-1.5 px-3 rounded-sm transition-colors ${activeTab === 'comments' ? 'bg-bg-surface text-text-heading shadow-sm' : 'text-text-muted hover:text-text-primary'
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
Comments ({comments.length})
|
|
38
|
+
</button>
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => setActiveTab('logs')}
|
|
41
|
+
className={`flex-1 text-xs font-medium py-1.5 px-3 rounded-sm transition-colors ${activeTab === 'logs' ? 'bg-bg-surface text-text-heading shadow-sm' : 'text-text-muted hover:text-text-primary'
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
Audit Logs ({logs.length})
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
|
49
|
+
|
|
50
|
+
{activeTab === 'comments' ? (
|
|
51
|
+
<>
|
|
52
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
53
|
+
{comments.length === 0 && (
|
|
54
|
+
<p className="text-center text-xs text-text-muted py-8">No comments yet. Start the conversation!</p>
|
|
55
|
+
)}
|
|
56
|
+
{comments.map((comment) => (
|
|
57
|
+
<div key={comment.id} className="flex flex-col gap-1.5">
|
|
58
|
+
<div className="flex items-center justify-between">
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
<span className="text-xs font-semibold text-text-heading">{comment.author}</span>
|
|
61
|
+
<Badge variant="outline" className="text-[10px] h-4 px-1 py-0">{comment.role}</Badge>
|
|
62
|
+
</div>
|
|
63
|
+
<span className="text-[10px] text-text-muted">{new Date(comment.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="bg-bg-surface p-3 rounded-lg border border-border-primary text-sm text-text-primary shadow-sm">
|
|
66
|
+
{comment.text}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
<div className="p-4 border-t border-border-primary bg-bg-surface">
|
|
72
|
+
<div className="relative">
|
|
73
|
+
<Textarea
|
|
74
|
+
placeholder="Type a comment..."
|
|
75
|
+
value={newComment}
|
|
76
|
+
onChange={(e) => setNewComment(e.target.value)}
|
|
77
|
+
className="min-h-[80px] resize-none pr-10"
|
|
78
|
+
onKeyDown={(e) => {
|
|
79
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
handleSubmit();
|
|
82
|
+
}
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
<Button
|
|
86
|
+
size="icon"
|
|
87
|
+
className="absolute bottom-2 right-2 h-8 w-8"
|
|
88
|
+
onClick={handleSubmit}
|
|
89
|
+
disabled={!newComment.trim()}
|
|
90
|
+
>
|
|
91
|
+
<Send size={14} />
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
98
|
+
{logs.length === 0 && (
|
|
99
|
+
<p className="text-center text-xs text-text-muted py-8">No specific logs record.</p>
|
|
100
|
+
)}
|
|
101
|
+
{logs.map((log) => (
|
|
102
|
+
<div key={log.id} className="flex gap-3 relative pb-4 last:pb-0">
|
|
103
|
+
{/* Timeline Line */}
|
|
104
|
+
<div className="absolute left-[11px] top-6 bottom-0 w-px bg-border-primary last:hidden"></div>
|
|
105
|
+
|
|
106
|
+
<div className="z-10 mt-0.5">
|
|
107
|
+
<div className={`h-6 w-6 rounded-full flex items-center justify-center border ${log.action.includes('Approved') ? 'bg-green-100 border-green-300 text-green-700' :
|
|
108
|
+
log.action.includes('Rejected') ? 'bg-red-100 border-red-300 text-red-700' :
|
|
109
|
+
'bg-bg-surface border-border-primary text-text-muted'
|
|
110
|
+
}`}>
|
|
111
|
+
{log.action.includes('Approved') ? <Shield size={12} /> :
|
|
112
|
+
log.action.includes('Rejected') ? <Shield size={12} /> :
|
|
113
|
+
<Clock size={12} />}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="flex-1">
|
|
117
|
+
<p className="text-xs font-medium text-text-heading">{log.action}</p>
|
|
118
|
+
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
|
|
119
|
+
<span className="flex items-center gap-1"><UserIcon size={10} /> {log.user}</span>
|
|
120
|
+
<span>•</span>
|
|
121
|
+
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
|
122
|
+
</div>
|
|
123
|
+
{log.details && (
|
|
124
|
+
<p className="mt-1 text-[11px] text-text-secondary italic">"{log.details}"</p>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, Input, Label, Button } from "@/components/ui/basic"
|
|
5
|
+
import { Save, MessageSquare, CheckCircle, AlertCircle, RefreshCw, ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
export function CopilotConfigPanel() {
|
|
8
|
+
const [loading, setLoading] = React.useState(true)
|
|
9
|
+
const [saveStatus, setSaveStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
|
10
|
+
const [errorMsg, setErrorMsg] = React.useState("")
|
|
11
|
+
const [isLocked, setIsLocked] = React.useState(false)
|
|
12
|
+
|
|
13
|
+
// Connection state
|
|
14
|
+
const [connStatus, setConnStatus] = React.useState<"idle" | "checking" | "verified" | "invalid">("idle")
|
|
15
|
+
const [connError, setConnError] = React.useState("")
|
|
16
|
+
const [availableModels, setAvailableModels] = React.useState<string[]>([])
|
|
17
|
+
const [showDropdown, setShowDropdown] = React.useState(false)
|
|
18
|
+
const [showApiKey, setShowApiKey] = React.useState(false)
|
|
19
|
+
|
|
20
|
+
const [formData, setFormData] = React.useState({
|
|
21
|
+
providerType: "custom_api",
|
|
22
|
+
apiEndpointUrl: "https://openrouter.ai/api/v1",
|
|
23
|
+
apiSecretKey: "",
|
|
24
|
+
modelName: "",
|
|
25
|
+
maxTokens: 4096,
|
|
26
|
+
temperature: 0.3,
|
|
27
|
+
isActive: true,
|
|
28
|
+
purpose: "copilot",
|
|
29
|
+
langsmithApiKey: "",
|
|
30
|
+
langsmithProject: "omnirad-copilot",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
// Load copilot-specific config
|
|
35
|
+
fetch("/api/ai-config?purpose=copilot")
|
|
36
|
+
.then(res => res.json())
|
|
37
|
+
.then(data => {
|
|
38
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
39
|
+
const active = data.find((d: any) => d.purpose === "copilot" && d.isActive) || data.find((d: any) => d.purpose === "copilot") || null
|
|
40
|
+
if (active) {
|
|
41
|
+
setFormData({
|
|
42
|
+
providerType: active.providerType || "custom_api",
|
|
43
|
+
apiEndpointUrl: active.apiEndpointUrl || "",
|
|
44
|
+
apiSecretKey: active.apiSecretKey || "",
|
|
45
|
+
modelName: active.modelName || "",
|
|
46
|
+
maxTokens: active.maxTokens || 4096,
|
|
47
|
+
temperature: active.temperature || 0.3,
|
|
48
|
+
isActive: true,
|
|
49
|
+
purpose: "copilot",
|
|
50
|
+
langsmithApiKey: active.langsmithApiKey || "",
|
|
51
|
+
langsmithProject: active.langsmithProject || "omnirad-copilot",
|
|
52
|
+
})
|
|
53
|
+
if (active.apiEndpointUrl && (active.apiSecretKey || active.providerType === "ollama")) {
|
|
54
|
+
checkConnection(active.providerType, active.apiEndpointUrl, active.apiSecretKey)
|
|
55
|
+
setIsLocked(true)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch(console.error)
|
|
61
|
+
.finally(() => setLoading(false))
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
65
|
+
const { id, value } = e.target
|
|
66
|
+
const fieldName = id.replace("copilot_", "")
|
|
67
|
+
setFormData(prev => ({ ...prev, [fieldName]: value }))
|
|
68
|
+
|
|
69
|
+
if (fieldName === "apiEndpointUrl" || fieldName === "apiSecretKey" || fieldName === "providerType") {
|
|
70
|
+
setConnStatus("idle")
|
|
71
|
+
setAvailableModels([])
|
|
72
|
+
if (fieldName === "providerType") {
|
|
73
|
+
if (value === "ollama") {
|
|
74
|
+
setFormData(prev => ({ ...prev, providerType: value, apiEndpointUrl: "http://localhost:11434", modelName: "" }))
|
|
75
|
+
} else if (value === "custom_api") {
|
|
76
|
+
setFormData(prev => ({ ...prev, providerType: value, apiEndpointUrl: "https://openrouter.ai/api/v1", modelName: "" }))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
setSaveStatus("idle")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const checkConnection = async (type = formData.providerType, url = formData.apiEndpointUrl, key = formData.apiSecretKey) => {
|
|
84
|
+
setConnStatus("checking")
|
|
85
|
+
setConnError("")
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch("http://localhost:8001/test_ai_connection", {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ ai_config: { providerType: type, apiEndpointUrl: url, apiSecretKey: key } })
|
|
91
|
+
})
|
|
92
|
+
const data = await res.json()
|
|
93
|
+
if (res.ok && data.success) {
|
|
94
|
+
setAvailableModels(data.models || [])
|
|
95
|
+
setConnStatus("verified")
|
|
96
|
+
} else {
|
|
97
|
+
setConnStatus("invalid")
|
|
98
|
+
setConnError(data.error || "Failed to fetch models.")
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
setConnStatus("invalid")
|
|
102
|
+
setConnError("Ensure the Python backend is running.")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleSave = async () => {
|
|
107
|
+
if (!formData.apiEndpointUrl) { setErrorMsg("Endpoint URL is required."); setSaveStatus("error"); return }
|
|
108
|
+
if (formData.providerType !== "ollama" && !formData.apiSecretKey) { setErrorMsg("API Key is required."); setSaveStatus("error"); return }
|
|
109
|
+
if (!formData.modelName) { setErrorMsg("Select a model first."); setSaveStatus("error"); return }
|
|
110
|
+
|
|
111
|
+
setSaveStatus("saving")
|
|
112
|
+
setErrorMsg("")
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch("/api/ai-config", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ ...formData, purpose: "copilot" }),
|
|
118
|
+
})
|
|
119
|
+
const result = await res.json()
|
|
120
|
+
if (res.ok && result.success) {
|
|
121
|
+
setSaveStatus("saved")
|
|
122
|
+
setIsLocked(true)
|
|
123
|
+
setTimeout(() => setSaveStatus("idle"), 3000)
|
|
124
|
+
} else {
|
|
125
|
+
setErrorMsg(result.error || "Failed to save")
|
|
126
|
+
setSaveStatus("error")
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
setErrorMsg(String(e))
|
|
130
|
+
setSaveStatus("error")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (loading) return null
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Card className="bg-bg-surface border-border-primary border-t-4 border-t-emerald-500">
|
|
138
|
+
<CardHeader>
|
|
139
|
+
<CardTitle className="text-text-heading flex items-center gap-2">
|
|
140
|
+
<MessageSquare size={20} className="text-emerald-500" />
|
|
141
|
+
AI Copilot Chat Configuration
|
|
142
|
+
</CardTitle>
|
|
143
|
+
<p className="text-sm text-text-secondary">
|
|
144
|
+
Configure a separate AI model for the Copilot chat assistant. This can be a different model from report generation.
|
|
145
|
+
</p>
|
|
146
|
+
</CardHeader>
|
|
147
|
+
<CardContent className="space-y-5">
|
|
148
|
+
{/* Provider Type */}
|
|
149
|
+
<div>
|
|
150
|
+
<Label htmlFor="copilot_providerType" className="text-text-primary">Provider Type</Label>
|
|
151
|
+
<select
|
|
152
|
+
id="copilot_providerType"
|
|
153
|
+
value={formData.providerType}
|
|
154
|
+
onChange={handleChange}
|
|
155
|
+
disabled={isLocked}
|
|
156
|
+
className="flex h-10 w-full rounded-md border border-border-primary bg-bg-panel disabled:opacity-50 px-3 py-2 text-sm text-text-primary mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
157
|
+
>
|
|
158
|
+
<option value="custom_api">Custom API (OpenAI / OpenRouter / etc.)</option>
|
|
159
|
+
<option value="ollama">Ollama (Local Models)</option>
|
|
160
|
+
</select>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Endpoint URL */}
|
|
164
|
+
<div>
|
|
165
|
+
<Label htmlFor="copilot_apiEndpointUrl" className="text-text-primary">API Endpoint URL</Label>
|
|
166
|
+
<Input
|
|
167
|
+
id="copilot_apiEndpointUrl"
|
|
168
|
+
value={formData.apiEndpointUrl}
|
|
169
|
+
onChange={handleChange}
|
|
170
|
+
disabled={isLocked}
|
|
171
|
+
placeholder={formData.providerType === "ollama" ? "http://localhost:11434" : "https://openrouter.ai/api/v1"}
|
|
172
|
+
className="mt-1 bg-bg-panel border-border-primary text-text-primary disabled:opacity-50"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* API Key */}
|
|
177
|
+
{formData.providerType !== "ollama" && (
|
|
178
|
+
<div>
|
|
179
|
+
<Label htmlFor="copilot_apiSecretKey" className="text-text-primary">API Secret Key</Label>
|
|
180
|
+
<div className="relative mt-1">
|
|
181
|
+
<Input
|
|
182
|
+
id="copilot_apiSecretKey"
|
|
183
|
+
type={showApiKey ? "text" : "password"}
|
|
184
|
+
value={formData.apiSecretKey}
|
|
185
|
+
onChange={handleChange}
|
|
186
|
+
disabled={isLocked}
|
|
187
|
+
placeholder="Enter your API key"
|
|
188
|
+
className="bg-bg-panel border-border-primary text-text-primary disabled:opacity-50 pr-10"
|
|
189
|
+
/>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => setShowApiKey(!showApiKey)}
|
|
193
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
|
194
|
+
>
|
|
195
|
+
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{/* Check Connection */}
|
|
202
|
+
<Button
|
|
203
|
+
variant="outline"
|
|
204
|
+
onClick={() => checkConnection()}
|
|
205
|
+
disabled={connStatus === "checking" || isLocked}
|
|
206
|
+
className="w-full border-emerald-500 text-emerald-500 hover:bg-emerald-50 gap-2 disabled:opacity-50"
|
|
207
|
+
>
|
|
208
|
+
<RefreshCw size={16} className={connStatus === "checking" ? "animate-spin" : ""} />
|
|
209
|
+
{connStatus === "checking" ? "Connecting..." : "Check Connection & Fetch Models"}
|
|
210
|
+
</Button>
|
|
211
|
+
|
|
212
|
+
{connStatus === "invalid" && (
|
|
213
|
+
<div className="text-xs text-red-500 bg-red-500/10 p-2 rounded-md border border-red-500/20">
|
|
214
|
+
<strong>Connection Failed:</strong> {connError}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
{connStatus === "verified" && (
|
|
218
|
+
<div className="text-xs text-green-600 dark:text-green-400 bg-green-500/10 p-2 rounded-md border border-green-500/20 flex items-center gap-1.5">
|
|
219
|
+
<CheckCircle size={14} /> <strong>Connection Verified:</strong> {availableModels.length} models available.
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<div className="pt-2 border-t border-border-primary"></div>
|
|
224
|
+
|
|
225
|
+
{/* Model Selection */}
|
|
226
|
+
<div className="relative">
|
|
227
|
+
<Label htmlFor="copilot_modelName" className="text-text-primary">Model Name</Label>
|
|
228
|
+
<div className="relative mt-1">
|
|
229
|
+
<Input
|
|
230
|
+
id="copilot_modelName"
|
|
231
|
+
value={formData.modelName}
|
|
232
|
+
onChange={(e) => { handleChange(e); setShowDropdown(true) }}
|
|
233
|
+
onFocus={() => setShowDropdown(true)}
|
|
234
|
+
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
|
235
|
+
disabled={connStatus !== "verified" || isLocked}
|
|
236
|
+
placeholder={connStatus === "verified" ? "Type or select a model..." : "Check connection first..."}
|
|
237
|
+
className="bg-bg-panel border-border-primary text-text-primary pr-10 disabled:opacity-50"
|
|
238
|
+
autoComplete="off"
|
|
239
|
+
/>
|
|
240
|
+
{connStatus === "verified" && !isLocked && (
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onMouseDown={(e) => { e.preventDefault(); setShowDropdown(!showDropdown) }}
|
|
244
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
|
245
|
+
>
|
|
246
|
+
{showDropdown ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
{showDropdown && connStatus === "verified" && availableModels.length > 0 && (
|
|
251
|
+
<ul className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-primary bg-bg-panel py-1 text-sm shadow-xl">
|
|
252
|
+
{(availableModels.includes(formData.modelName)
|
|
253
|
+
? availableModels
|
|
254
|
+
: availableModels.filter(m => m.toLowerCase().includes(formData.modelName.toLowerCase()))
|
|
255
|
+
).map(name => (
|
|
256
|
+
<li
|
|
257
|
+
key={name}
|
|
258
|
+
onMouseDown={(e) => {
|
|
259
|
+
e.preventDefault()
|
|
260
|
+
setFormData(prev => ({ ...prev, modelName: name }))
|
|
261
|
+
setShowDropdown(false)
|
|
262
|
+
}}
|
|
263
|
+
className="cursor-pointer px-3 py-2 text-text-primary hover:bg-slate-200 dark:hover:bg-emerald-600/30"
|
|
264
|
+
>
|
|
265
|
+
{name}
|
|
266
|
+
</li>
|
|
267
|
+
))}
|
|
268
|
+
</ul>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="pt-2 border-t border-border-primary"></div>
|
|
273
|
+
|
|
274
|
+
{/* LangSmith Configuration */}
|
|
275
|
+
<details className="group">
|
|
276
|
+
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary flex items-center gap-2">
|
|
277
|
+
<span className="group-open:rotate-90 transition-transform">▸</span>
|
|
278
|
+
LangSmith Tracing (Optional)
|
|
279
|
+
</summary>
|
|
280
|
+
<div className="mt-3 space-y-3 pl-4 border-l-2 border-emerald-500/20">
|
|
281
|
+
<div>
|
|
282
|
+
<Label htmlFor="copilot_langsmithApiKey" className="text-text-primary text-xs">LangSmith API Key</Label>
|
|
283
|
+
<Input
|
|
284
|
+
id="copilot_langsmithApiKey"
|
|
285
|
+
type="password"
|
|
286
|
+
value={formData.langsmithApiKey}
|
|
287
|
+
onChange={handleChange}
|
|
288
|
+
disabled={isLocked}
|
|
289
|
+
placeholder="lsv2_pt_..."
|
|
290
|
+
className="mt-1 bg-bg-panel border-border-primary text-text-primary disabled:opacity-50 text-sm"
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
<div>
|
|
294
|
+
<Label htmlFor="copilot_langsmithProject" className="text-text-primary text-xs">LangSmith Project Name</Label>
|
|
295
|
+
<Input
|
|
296
|
+
id="copilot_langsmithProject"
|
|
297
|
+
value={formData.langsmithProject}
|
|
298
|
+
onChange={handleChange}
|
|
299
|
+
disabled={isLocked}
|
|
300
|
+
placeholder="omnirad-copilot"
|
|
301
|
+
className="mt-1 bg-bg-panel border-border-primary text-text-primary disabled:opacity-50 text-sm"
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</details>
|
|
306
|
+
|
|
307
|
+
{/* Status messages */}
|
|
308
|
+
{saveStatus === "error" && errorMsg && (
|
|
309
|
+
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-2 text-red-400 text-sm">
|
|
310
|
+
<AlertCircle size={16} /> {errorMsg}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
{saveStatus === "saved" && (
|
|
314
|
+
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg flex items-center gap-2 text-green-400 text-sm">
|
|
315
|
+
<CheckCircle size={16} /> Copilot configuration saved and activated!
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{/* Save / Change button */}
|
|
320
|
+
{isLocked ? (
|
|
321
|
+
<Button onClick={() => setIsLocked(false)} className="w-full mt-4 bg-green-600 hover:bg-green-700 text-white gap-2">
|
|
322
|
+
<CheckCircle size={16} /> Change Configuration
|
|
323
|
+
</Button>
|
|
324
|
+
) : (
|
|
325
|
+
<Button
|
|
326
|
+
onClick={handleSave}
|
|
327
|
+
disabled={saveStatus === "saving" || connStatus !== "verified" || !formData.modelName}
|
|
328
|
+
className="w-full mt-4 bg-emerald-600 hover:bg-emerald-700 text-white gap-2 disabled:opacity-50"
|
|
329
|
+
>
|
|
330
|
+
<Save size={16} />
|
|
331
|
+
{saveStatus === "saving" ? "Saving..." : "Save & Activate Copilot"}
|
|
332
|
+
</Button>
|
|
333
|
+
)}
|
|
334
|
+
</CardContent>
|
|
335
|
+
</Card>
|
|
336
|
+
)
|
|
337
|
+
}
|