feat: Add Studio UI, intake system, and extractor improvements

Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

@@ -0,0 +1,455 @@
/**
* InboxStudyCard - Card displaying an inbox study with actions
*
* Shows study status, files, and provides actions for:
* - Running introspection
* - Generating README
* - Finalizing the study
*/
import React, { useState, useEffect } from 'react';
import {
FileText,
Folder,
Trash2,
Play,
CheckCircle,
Clock,
AlertCircle,
Loader2,
ChevronDown,
ChevronRight,
Sparkles,
ArrowRight,
Eye,
Save,
} from 'lucide-react';
import { InboxStudy, SpecStatus, ExpressionInfo, InboxStudyDetail } from '../../types/intake';
import { intakeApi } from '../../api/intake';
import { FileDropzone } from './FileDropzone';
import { ContextFileUpload } from './ContextFileUpload';
import { ExpressionList } from './ExpressionList';
interface InboxStudyCardProps {
study: InboxStudy;
onRefresh: () => void;
onSelect: (studyName: string) => void;
}
const statusConfig: Record<SpecStatus, { icon: React.ReactNode; color: string; label: string }> = {
draft: {
icon: <Clock className="w-4 h-4" />,
color: 'text-dark-400 bg-dark-600',
label: 'Draft',
},
introspected: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-blue-400 bg-blue-500/10',
label: 'Introspected',
},
configured: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Configured',
},
validated: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Validated',
},
ready: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-primary-400 bg-primary-500/10',
label: 'Ready',
},
running: {
icon: <Play className="w-4 h-4" />,
color: 'text-yellow-400 bg-yellow-500/10',
label: 'Running',
},
completed: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Completed',
},
failed: {
icon: <AlertCircle className="w-4 h-4" />,
color: 'text-red-400 bg-red-500/10',
label: 'Failed',
},
};
export const InboxStudyCard: React.FC<InboxStudyCardProps> = ({
study,
onRefresh,
onSelect,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isIntrospecting, setIsIntrospecting] = useState(false);
const [isGeneratingReadme, setIsGeneratingReadme] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Introspection data (fetched when expanded)
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [selectedExpressions, setSelectedExpressions] = useState<string[]>([]);
const [showReadme, setShowReadme] = useState(false);
const [readmeContent, setReadmeContent] = useState<string | null>(null);
const [isSavingDVs, setIsSavingDVs] = useState(false);
const [dvSaveMessage, setDvSaveMessage] = useState<string | null>(null);
const status = statusConfig[study.status] || statusConfig.draft;
// Fetch study details when expanded for the first time
useEffect(() => {
if (isExpanded && !studyDetail && !isLoadingDetail) {
loadStudyDetail();
}
}, [isExpanded]);
const loadStudyDetail = async () => {
setIsLoadingDetail(true);
try {
const detail = await intakeApi.getInboxStudy(study.study_name);
setStudyDetail(detail);
// Auto-select candidate expressions
const introspection = detail.spec?.model?.introspection;
if (introspection?.expressions) {
const candidates = introspection.expressions
.filter((e: ExpressionInfo) => e.is_candidate)
.map((e: ExpressionInfo) => e.name);
setSelectedExpressions(candidates);
}
} catch (err) {
console.error('Failed to load study detail:', err);
} finally {
setIsLoadingDetail(false);
}
};
const handleIntrospect = async () => {
setIsIntrospecting(true);
setError(null);
try {
await intakeApi.introspect({ study_name: study.study_name });
// Reload study detail to get new introspection data
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Introspection failed');
} finally {
setIsIntrospecting(false);
}
};
const handleGenerateReadme = async () => {
setIsGeneratingReadme(true);
setError(null);
try {
const response = await intakeApi.generateReadme(study.study_name);
setReadmeContent(response.content);
setShowReadme(true);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'README generation failed');
} finally {
setIsGeneratingReadme(false);
}
};
const handleDelete = async () => {
if (!confirm(`Delete inbox study "${study.study_name}"? This cannot be undone.`)) {
return;
}
setIsDeleting(true);
try {
await intakeApi.deleteInboxStudy(study.study_name);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed');
setIsDeleting(false);
}
};
const handleSaveDesignVariables = async () => {
if (selectedExpressions.length === 0) {
setError('Please select at least one expression to use as a design variable');
return;
}
setIsSavingDVs(true);
setError(null);
setDvSaveMessage(null);
try {
const result = await intakeApi.createDesignVariables(study.study_name, selectedExpressions);
setDvSaveMessage(`Created ${result.total_created} design variable(s)`);
// Reload study detail to see updated spec
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save design variables');
} finally {
setIsSavingDVs(false);
}
};
const canIntrospect = study.status === 'draft' && study.model_files.length > 0;
const canGenerateReadme = study.status === 'introspected';
const canFinalize = ['introspected', 'configured'].includes(study.status);
const canSaveDVs = study.status === 'introspected' && selectedExpressions.length > 0;
return (
<div className="glass rounded-xl border border-primary-400/10 overflow-hidden">
{/* Header - Always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center">
<Folder className="w-5 h-5 text-primary-400" />
</div>
<div className="text-left">
<h4 className="text-white font-medium">{study.study_name}</h4>
{study.description && (
<p className="text-sm text-dark-400 truncate max-w-[300px]">
{study.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Status Badge */}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.color}`}>
{status.icon}
{status.label}
</span>
{/* File Count */}
<span className="text-dark-500 text-sm">
{study.model_files.length} files
</span>
{/* Expand Icon */}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-dark-400" />
) : (
<ChevronRight className="w-4 h-4 text-dark-400" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-4 pb-4 space-y-4 border-t border-primary-400/10 pt-4">
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Success Message */}
{dvSaveMessage && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4 flex-shrink-0" />
{dvSaveMessage}
</div>
)}
{/* Files Section */}
{study.model_files.length > 0 && (
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Model Files</h5>
<div className="flex flex-wrap gap-2">
{study.model_files.map((file) => (
<span
key={file}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-dark-300 text-xs"
>
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Model File Upload Section */}
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Upload Model Files</h5>
<FileDropzone
studyName={study.study_name}
onUploadComplete={onRefresh}
compact={true}
/>
</div>
{/* Context File Upload Section */}
<ContextFileUpload
studyName={study.study_name}
onUploadComplete={onRefresh}
/>
{/* Introspection Results - Expressions */}
{isLoadingDetail && (
<div className="flex items-center gap-2 text-dark-400 text-sm py-4">
<Loader2 className="w-4 h-4 animate-spin" />
Loading introspection data...
</div>
)}
{studyDetail?.spec?.model?.introspection?.expressions &&
studyDetail.spec.model.introspection.expressions.length > 0 && (
<ExpressionList
expressions={studyDetail.spec.model.introspection.expressions}
massKg={studyDetail.spec.model.introspection.mass_kg}
selectedExpressions={selectedExpressions}
onSelectionChange={setSelectedExpressions}
readOnly={study.status === 'configured'}
compact={true}
/>
)}
{/* README Preview Section */}
{(readmeContent || study.status === 'configured') && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<FileText className="w-4 h-4" />
README.md
</h5>
<button
onClick={() => setShowReadme(!showReadme)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-dark-600
text-dark-300 hover:bg-dark-500 transition-colors"
>
<Eye className="w-3 h-3" />
{showReadme ? 'Hide' : 'Preview'}
</button>
</div>
{showReadme && readmeContent && (
<div className="max-h-64 overflow-y-auto rounded-lg border border-dark-600
bg-dark-800 p-4">
<pre className="text-xs text-dark-300 whitespace-pre-wrap font-mono">
{readmeContent}
</pre>
</div>
)}
</div>
)}
{/* No Files Warning */}
{study.model_files.length === 0 && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
No model files found. Upload .prt, .sim, or .fem files to continue.
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2">
{/* Introspect */}
{canIntrospect && (
<button
onClick={handleIntrospect}
disabled={isIntrospecting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-blue-500/10 text-blue-400 hover:bg-blue-500/20
disabled:opacity-50 transition-colors"
>
{isIntrospecting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
Introspect Model
</button>
)}
{/* Save Design Variables */}
{canSaveDVs && (
<button
onClick={handleSaveDesignVariables}
disabled={isSavingDVs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-green-500/10 text-green-400 hover:bg-green-500/20
disabled:opacity-50 transition-colors"
>
{isSavingDVs ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save as DVs ({selectedExpressions.length})
</button>
)}
{/* Generate README */}
{canGenerateReadme && (
<button
onClick={handleGenerateReadme}
disabled={isGeneratingReadme}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
disabled:opacity-50 transition-colors"
>
{isGeneratingReadme ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Generate README
</button>
)}
{/* Finalize */}
{canFinalize && (
<button
onClick={() => onSelect(study.study_name)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
transition-colors"
>
<ArrowRight className="w-4 h-4" />
Finalize Study
</button>
)}
{/* Delete */}
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-red-500/10 text-red-400 hover:bg-red-500/20
disabled:opacity-50 transition-colors ml-auto"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</button>
</div>
{/* Workflow Hint */}
{study.status === 'draft' && study.model_files.length > 0 && (
<p className="text-xs text-dark-500">
Next step: Run introspection to discover expressions and model properties.
</p>
)}
{study.status === 'introspected' && (
<p className="text-xs text-dark-500">
Next step: Generate README with Claude AI, then finalize to create the study.
</p>
)}
</div>
)}
</div>
);
};
export default InboxStudyCard;