docs: Comprehensive documentation update for Dashboard V3 and Canvas
## Documentation Updates - DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection - DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE - CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features - CLAUDE.md: Added dashboard quick reference and Canvas V3 features ## Canvas V3 Features Documented - File Browser: Browse studies directory for model files - Model Introspection: Auto-discover expressions, solver type, dependencies - One-Click Add: Add expressions as design variables instantly - Claude Bug Fixes: WebSocket reconnection, SQL errors resolved - Health Check: /api/health endpoint for monitoring ## Backend Services - NX introspection service with expression discovery - File browser API with type filtering - Claude session management improvements - Context builder enhancements ## Frontend Components - FileBrowser: Modal for file selection with search - IntrospectionPanel: View discovered model information - ExpressionSelector: Dropdown for design variable configuration - Improved chat hooks with reconnection logic ## Plan Documents - Added RALPH_LOOP_CANVAS_V2/V3 implementation records - Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN - Added investigation and sync documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400">
|
||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||
{data.expressionName ? (
|
||||
<span className="font-mono">{data.expressionName}</span>
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ModelNodeData } from '../../../lib/canvas/schema';
|
||||
function ModelNodeComponent(props: NodeProps<ModelNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<Box size={16} />} iconColor="text-blue-400" inputs={0}>
|
||||
<BaseNode {...props} icon={<Box size={16} />} iconColor="text-blue-400" inputs={1} outputs={1}>
|
||||
{data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'}
|
||||
</BaseNode>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* File Browser - Modal for selecting NX model files
|
||||
* File Browser - Modal for selecting and importing NX model files
|
||||
*
|
||||
* Supports three methods:
|
||||
* 1. Browse existing files in studies folder
|
||||
* 2. Import from Windows path (paste full path)
|
||||
* 3. Upload files directly
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
X,
|
||||
Folder,
|
||||
@@ -12,6 +17,12 @@ import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Home,
|
||||
Upload,
|
||||
FolderInput,
|
||||
FileUp,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FileBrowserProps {
|
||||
@@ -20,6 +31,7 @@ interface FileBrowserProps {
|
||||
onSelect: (filePath: string, fileType: string) => void;
|
||||
fileTypes?: string[];
|
||||
initialPath?: string;
|
||||
studyName?: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
@@ -29,19 +41,57 @@ interface FileEntry {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface RelatedFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
type TabType = 'browse' | 'import' | 'upload';
|
||||
|
||||
export function FileBrowser({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
fileTypes = ['.sim', '.prt', '.fem', '.afem'],
|
||||
initialPath = '',
|
||||
studyName = 'new_study',
|
||||
}: FileBrowserProps) {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabType>('browse');
|
||||
|
||||
// Browse tab state
|
||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Import tab state
|
||||
const [importPath, setImportPath] = useState('');
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
related_files?: RelatedFile[];
|
||||
} | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
success: boolean;
|
||||
imported_files?: { name: string; status: string; path?: string }[];
|
||||
} | null>(null);
|
||||
|
||||
// Upload tab state
|
||||
const [uploadStudyName, setUploadStudyName] = useState(studyName);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
success: boolean;
|
||||
uploaded_files?: { name: string; status: string; path?: string }[];
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load directory for browse tab
|
||||
const loadDirectory = useCallback(async (path: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -67,11 +117,124 @@ export function FileBrowser({
|
||||
}, [fileTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && activeTab === 'browse') {
|
||||
loadDirectory(currentPath);
|
||||
}
|
||||
}, [isOpen, currentPath, loadDirectory]);
|
||||
}, [isOpen, currentPath, loadDirectory, activeTab]);
|
||||
|
||||
// Validate external path
|
||||
const validatePath = async () => {
|
||||
if (!importPath.trim()) return;
|
||||
|
||||
setValidating(true);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files/validate-path?path=${encodeURIComponent(importPath.trim())}`
|
||||
);
|
||||
const data = await res.json();
|
||||
setValidationResult(data);
|
||||
} catch (e) {
|
||||
setValidationResult({ valid: false, error: 'Failed to validate path' });
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Import from path
|
||||
const handleImport = async () => {
|
||||
if (!validationResult?.valid) return;
|
||||
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/files/import-from-path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_path: importPath.trim(),
|
||||
study_name: uploadStudyName,
|
||||
copy_related: true,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setImportResult(data);
|
||||
// Find the main file that was imported
|
||||
const mainFile = data.imported_files?.find(
|
||||
(f: { name: string; status: string; path?: string }) =>
|
||||
f.status === 'imported' || f.status === 'skipped'
|
||||
);
|
||||
if (mainFile?.path) {
|
||||
// Auto-select the imported file
|
||||
const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase();
|
||||
onSelect(mainFile.path, ext);
|
||||
setTimeout(onClose, 1500);
|
||||
}
|
||||
} else {
|
||||
setImportResult({ success: false });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e);
|
||||
setImportResult({ success: false });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
formData.append('files', selectedFiles[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files/upload?study_name=${encodeURIComponent(uploadStudyName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setUploadResult(data);
|
||||
// Find the first uploaded file
|
||||
const mainFile = data.uploaded_files?.find(
|
||||
(f: { name: string; status: string; path?: string }) => f.status === 'uploaded'
|
||||
);
|
||||
if (mainFile?.path) {
|
||||
const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase();
|
||||
onSelect(mainFile.path, ext);
|
||||
setTimeout(onClose, 1500);
|
||||
}
|
||||
} else {
|
||||
setUploadResult({ success: false });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e);
|
||||
setUploadResult({ success: false });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Browse tab handlers
|
||||
const handleSelect = (file: FileEntry) => {
|
||||
if (file.isDirectory) {
|
||||
setCurrentPath(file.path);
|
||||
@@ -117,124 +280,407 @@ export function FileBrowser({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-dark-700">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
|
||||
<span>Looking for:</span>
|
||||
{fileTypes.map((t) => (
|
||||
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path breadcrumb */}
|
||||
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700 overflow-x-auto">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-dark-700">
|
||||
<button
|
||||
onClick={() => navigateTo('')}
|
||||
className="hover:text-white flex items-center gap-1 flex-shrink-0"
|
||||
onClick={() => setActiveTab('browse')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
|
||||
${activeTab === 'browse' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
|
||||
>
|
||||
<Home size={14} />
|
||||
<span>studies</span>
|
||||
<Folder size={16} />
|
||||
Browse Studies
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
|
||||
${activeTab === 'import' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
|
||||
>
|
||||
<FolderInput size={16} />
|
||||
Import Path
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
|
||||
${activeTab === 'upload' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Upload Files
|
||||
</button>
|
||||
{pathParts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1 flex-shrink-0">
|
||||
<ChevronRight size={14} />
|
||||
<button
|
||||
onClick={() => navigateTo(pathParts.slice(0, i + 1).join('/'))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-32 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
{searchTerm ? 'No matching files found' : 'No model files in this directory'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Show parent directory link if not at root */}
|
||||
{currentPath && (
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Browse Tab */}
|
||||
{activeTab === 'browse' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-dark-700">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
|
||||
<span>Looking for:</span>
|
||||
{fileTypes.map((t) => (
|
||||
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path breadcrumb */}
|
||||
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700 overflow-x-auto">
|
||||
<button
|
||||
onClick={navigateUp}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
|
||||
hover:bg-dark-700 transition-colors text-dark-300"
|
||||
onClick={() => navigateTo('')}
|
||||
className="hover:text-white flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<ChevronDown size={16} className="text-dark-500 rotate-90" />
|
||||
<Folder size={16} className="text-dark-400" />
|
||||
<span>..</span>
|
||||
<Home size={14} />
|
||||
<span>studies</span>
|
||||
</button>
|
||||
{pathParts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1 flex-shrink-0">
|
||||
<ChevronRight size={14} />
|
||||
<button
|
||||
onClick={() => navigateTo(pathParts.slice(0, i + 1).join('/'))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-32 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
{searchTerm ? 'No matching files found' : 'No model files in this directory'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={navigateUp}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
|
||||
hover:bg-dark-700 transition-colors text-dark-300"
|
||||
>
|
||||
<ChevronDown size={16} className="text-dark-500 rotate-90" />
|
||||
<Folder size={16} className="text-dark-400" />
|
||||
<span>..</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filteredFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => handleSelect(file)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
|
||||
hover:bg-dark-700 transition-colors
|
||||
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
|
||||
>
|
||||
{file.isDirectory ? (
|
||||
<>
|
||||
<ChevronRight size={16} className="text-dark-500" />
|
||||
<Folder size={16} className="text-amber-400" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4" />
|
||||
<FileBox size={16} className="text-primary-400" />
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
{!file.isDirectory && (
|
||||
<span className="text-xs text-dark-500 uppercase">
|
||||
{file.name.split('.').pop()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Import Tab */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Windows File Path
|
||||
</label>
|
||||
<p className="text-xs text-dark-500 mb-2">
|
||||
Paste the full path to your NX model file (e.g., C:\Models\bracket_sim1.sim)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={importPath}
|
||||
onChange={(e) => {
|
||||
setImportPath(e.target.value);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
}}
|
||||
placeholder="C:\path\to\model.sim"
|
||||
className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-500 font-mono text-sm
|
||||
focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
onClick={validatePath}
|
||||
disabled={!importPath.trim() || validating}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:bg-dark-600
|
||||
text-white rounded-lg text-sm font-medium transition-colors
|
||||
flex items-center gap-2"
|
||||
>
|
||||
{validating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
||||
Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{validationResult && (
|
||||
<div className={`p-3 rounded-lg border ${
|
||||
validationResult.valid
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
}`}>
|
||||
{validationResult.valid ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-medium">
|
||||
<CheckCircle size={16} />
|
||||
Path validated
|
||||
</div>
|
||||
{validationResult.related_files && validationResult.related_files.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-dark-400 mb-1">Related files to import:</p>
|
||||
<div className="space-y-1">
|
||||
{validationResult.related_files.map((f) => (
|
||||
<div key={f.path} className="flex items-center gap-2 text-xs text-dark-300">
|
||||
<FileBox size={12} className="text-primary-400" />
|
||||
<span className="font-mono">{f.name}</span>
|
||||
<span className="text-dark-500">{f.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle size={16} />
|
||||
{validationResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFiles.map((file) => (
|
||||
{/* Study Name for Import */}
|
||||
{validationResult?.valid && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Import to Study
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadStudyName}
|
||||
onChange={(e) => setUploadStudyName(e.target.value)}
|
||||
placeholder="study_name"
|
||||
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-500 text-sm
|
||||
focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Button */}
|
||||
{validationResult?.valid && (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => handleSelect(file)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
|
||||
hover:bg-dark-700 transition-colors
|
||||
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
className="w-full py-2.5 bg-emerald-500 hover:bg-emerald-600 disabled:bg-dark-600
|
||||
text-white rounded-lg font-medium transition-colors
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{file.isDirectory ? (
|
||||
{importing ? (
|
||||
<>
|
||||
<ChevronRight size={16} className="text-dark-500" />
|
||||
<Folder size={16} className="text-amber-400" />
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4" />
|
||||
<FileBox size={16} className="text-primary-400" />
|
||||
<FolderInput size={18} />
|
||||
Import All Files
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
{!file.isDirectory && (
|
||||
<span className="text-xs text-dark-500 uppercase">
|
||||
{file.name.split('.').pop()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Import Result */}
|
||||
{importResult && (
|
||||
<div className={`p-3 rounded-lg border ${
|
||||
importResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
}`}>
|
||||
{importResult.success ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-medium">
|
||||
<CheckCircle size={16} />
|
||||
Import successful!
|
||||
</div>
|
||||
{importResult.imported_files && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{importResult.imported_files.map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-2 text-xs">
|
||||
<span className={f.status === 'imported' ? 'text-emerald-400' : 'text-dark-400'}>
|
||||
{f.status === 'imported' ? '✓' : '○'}
|
||||
</span>
|
||||
<span className="text-dark-300 font-mono">{f.name}</span>
|
||||
<span className="text-dark-500">{f.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle size={16} />
|
||||
Import failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Tab */}
|
||||
{activeTab === 'upload' && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Study Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadStudyName}
|
||||
onChange={(e) => setUploadStudyName(e.target.value)}
|
||||
placeholder="study_name"
|
||||
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-500 text-sm
|
||||
focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-dark-600 rounded-xl p-8
|
||||
hover:border-primary-500/50 hover:bg-dark-800/50
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<FileUp size={40} className="text-dark-500 mb-3" />
|
||||
<p className="text-white font-medium">
|
||||
{uploading ? 'Uploading...' : 'Click to select files'}
|
||||
</p>
|
||||
<p className="text-dark-500 text-sm mt-1">
|
||||
or drag and drop NX model files
|
||||
</p>
|
||||
<p className="text-dark-600 text-xs mt-2">
|
||||
Supported: .prt, .sim, .fem, .afem
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".prt,.sim,.fem,.afem"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress/Result */}
|
||||
{uploading && (
|
||||
<div className="flex items-center justify-center gap-2 text-primary-400">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Uploading files...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<div className={`p-3 rounded-lg border ${
|
||||
uploadResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
}`}>
|
||||
{uploadResult.success ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-emerald-400 font-medium">
|
||||
<CheckCircle size={16} />
|
||||
Upload successful!
|
||||
</div>
|
||||
{uploadResult.uploaded_files && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{uploadResult.uploaded_files.map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-2 text-xs">
|
||||
<span className={f.status === 'uploaded' ? 'text-emerald-400' : 'text-red-400'}>
|
||||
{f.status === 'uploaded' ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-dark-300 font-mono">{f.name}</span>
|
||||
<span className="text-dark-500">{f.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle size={16} />
|
||||
Upload failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-dark-700 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => loadDirectory(currentPath)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
{activeTab === 'browse' && (
|
||||
<button
|
||||
onClick={() => loadDirectory(currentPath)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
{activeTab !== 'browse' && <div />}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-white transition-colors"
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
*
|
||||
* Bridges the Canvas UI with the Chat system, allowing canvas intents
|
||||
* to be sent to Claude for intelligent execution.
|
||||
*
|
||||
* Key features:
|
||||
* - Passes canvas state to Claude for context awareness
|
||||
* - Handles canvas modification instructions from Claude
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useChat, ChatMode } from './useChat';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useChat, ChatMode, CanvasState } from './useChat';
|
||||
import { OptimizationIntent, formatIntentForChat } from '../lib/canvas/intent';
|
||||
import { useCanvasStore } from './useCanvasStore';
|
||||
|
||||
interface UseCanvasChatOptions {
|
||||
mode?: ChatMode;
|
||||
onError?: (error: string) => void;
|
||||
studyName?: string;
|
||||
studyPath?: string;
|
||||
}
|
||||
|
||||
interface CanvasChatState {
|
||||
@@ -32,8 +39,39 @@ interface ExecutionResult {
|
||||
export function useCanvasChat({
|
||||
mode = 'user',
|
||||
onError,
|
||||
studyName,
|
||||
studyPath,
|
||||
}: UseCanvasChatOptions = {}) {
|
||||
const chat = useChat({ mode, onError });
|
||||
// Get canvas state from the store
|
||||
const { nodes, edges, addNode, updateNodeData } = useCanvasStore();
|
||||
|
||||
// Build canvas state object for chat context
|
||||
const canvasState: CanvasState = {
|
||||
nodes: nodes.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
data: n.data,
|
||||
position: n.position,
|
||||
})),
|
||||
edges: edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
})),
|
||||
studyName,
|
||||
studyPath,
|
||||
};
|
||||
|
||||
const chat = useChat({
|
||||
mode,
|
||||
onError,
|
||||
canvasState,
|
||||
});
|
||||
|
||||
// Sync canvas state to chat whenever it changes
|
||||
useEffect(() => {
|
||||
chat.updateCanvasState(canvasState);
|
||||
}, [nodes, edges, studyName, studyPath]);
|
||||
|
||||
const [state, setState] = useState<CanvasChatState>({
|
||||
isExecuting: false,
|
||||
@@ -156,6 +194,61 @@ ${question}`;
|
||||
[chat]
|
||||
);
|
||||
|
||||
/**
|
||||
* Apply a canvas modification from Claude's tool response
|
||||
* This is called when Claude uses canvas_add_node, canvas_update_node, etc.
|
||||
*/
|
||||
const applyModification = useCallback(
|
||||
(modification: {
|
||||
action: 'add_node' | 'update_node' | 'remove_node' | 'add_edge';
|
||||
nodeType?: string;
|
||||
nodeId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
source?: string;
|
||||
target?: string;
|
||||
}) => {
|
||||
switch (modification.action) {
|
||||
case 'add_node':
|
||||
if (modification.nodeType && modification.data) {
|
||||
// Calculate a position for the new node
|
||||
const existingNodesOfType = nodes.filter(n => n.type === modification.nodeType);
|
||||
const baseX = modification.nodeType === 'designVar' ? 50 : 740;
|
||||
const newY = 50 + existingNodesOfType.length * 100;
|
||||
|
||||
addNode(modification.nodeType as any, { x: baseX, y: newY }, modification.data as any);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_node':
|
||||
if (modification.nodeId && modification.data) {
|
||||
const findBy = (modification.data.findBy as string) || 'label';
|
||||
const updates = { ...modification.data };
|
||||
delete updates.findBy;
|
||||
|
||||
// Find node by ID or label
|
||||
let targetNode;
|
||||
if (findBy === 'id') {
|
||||
targetNode = nodes.find(n => n.id === modification.nodeId);
|
||||
} else {
|
||||
targetNode = nodes.find(n =>
|
||||
n.data?.label === modification.nodeId ||
|
||||
(n.data as any)?.expressionName === modification.nodeId ||
|
||||
(n.data as any)?.name === modification.nodeId
|
||||
);
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
updateNodeData(targetNode.id, updates as any);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// Add other cases as needed
|
||||
}
|
||||
},
|
||||
[nodes, addNode, updateNodeData]
|
||||
);
|
||||
|
||||
return {
|
||||
// Chat state
|
||||
messages: chat.messages,
|
||||
@@ -175,10 +268,12 @@ ${question}`;
|
||||
executeIntent,
|
||||
analyzeIntent,
|
||||
askAboutCanvas,
|
||||
applyModification,
|
||||
|
||||
// Base chat actions
|
||||
sendMessage: chat.sendMessage,
|
||||
clearMessages: chat.clearMessages,
|
||||
switchMode: chat.switchMode,
|
||||
updateCanvasState: chat.updateCanvasState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface CanvasState {
|
||||
loadFromConfig: (config: OptimizationConfig) => void;
|
||||
}
|
||||
|
||||
// Optimization config structure (from optimization_config.json)
|
||||
// Optimization config structure (matching actual optimization_config.json format)
|
||||
export interface OptimizationConfig {
|
||||
study_name?: string;
|
||||
model?: {
|
||||
@@ -39,27 +39,68 @@ export interface OptimizationConfig {
|
||||
type?: string;
|
||||
solution?: number;
|
||||
};
|
||||
// Design variables - actual format uses min/max, not lower/upper
|
||||
design_variables?: Array<{
|
||||
name: string;
|
||||
expression_name?: string;
|
||||
lower: number;
|
||||
upper: number;
|
||||
min: number;
|
||||
max: number;
|
||||
baseline?: number;
|
||||
units?: string;
|
||||
enabled?: boolean;
|
||||
notes?: string;
|
||||
type?: string;
|
||||
// Legacy support - some configs use lower/upper
|
||||
lower?: number;
|
||||
upper?: number;
|
||||
}>;
|
||||
// Extraction method for Zernike or other physics
|
||||
extraction_method?: {
|
||||
type?: 'zernike_opd' | 'displacement' | 'stress' | 'mass' | 'frequency';
|
||||
class?: string;
|
||||
method?: string;
|
||||
inner_radius?: number;
|
||||
outer_radius?: number;
|
||||
};
|
||||
// Zernike-specific settings
|
||||
zernike_settings?: {
|
||||
n_modes?: number;
|
||||
filter_low_orders?: number;
|
||||
subcases?: string[];
|
||||
subcase_labels?: Record<string, string>;
|
||||
reference_subcase?: string;
|
||||
};
|
||||
objectives?: Array<{
|
||||
name: string;
|
||||
direction?: string;
|
||||
weight?: number;
|
||||
extractor?: string;
|
||||
penalty_weight?: number; // For hard constraint conversion
|
||||
}>;
|
||||
constraints?: Array<{
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: number;
|
||||
extractor?: string;
|
||||
penalty_weight?: number;
|
||||
}>;
|
||||
// Hard constraints (common in real configs)
|
||||
hard_constraints?: Array<{
|
||||
name: string;
|
||||
limit: number;
|
||||
penalty_weight: number;
|
||||
}>;
|
||||
// Fixed parameters (not optimized)
|
||||
fixed_parameters?: Record<string, number | string>;
|
||||
// Optimization settings
|
||||
method?: string;
|
||||
max_trials?: number;
|
||||
optimization?: {
|
||||
sampler?: string;
|
||||
n_trials?: number;
|
||||
sigma0?: number;
|
||||
restart_strategy?: string;
|
||||
};
|
||||
surrogate?: {
|
||||
type?: string;
|
||||
min_trials?: number;
|
||||
@@ -340,19 +381,20 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadFromConfig: (config) => {
|
||||
// Complete rewrite: Create all nodes and edges directly from config
|
||||
// Complete rewrite: Create all nodes and edges from actual optimization_config.json
|
||||
nodeIdCounter = 0;
|
||||
const nodes: Node<CanvasNodeData>[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Column positions for proper layout
|
||||
const COLS = {
|
||||
modelDvar: 50,
|
||||
solver: 280,
|
||||
extractor: 510,
|
||||
objCon: 740,
|
||||
algo: 970,
|
||||
surrogate: 1200,
|
||||
designVar: 50,
|
||||
model: 280,
|
||||
solver: 510,
|
||||
extractor: 740,
|
||||
objCon: 1020,
|
||||
algo: 1300,
|
||||
surrogate: 1530,
|
||||
};
|
||||
const ROW_HEIGHT = 100;
|
||||
const START_Y = 50;
|
||||
@@ -370,64 +412,104 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
};
|
||||
|
||||
// 1. Model node
|
||||
const modelId = createNode('model', COLS.modelDvar, START_Y, {
|
||||
const modelId = createNode('model', COLS.model, START_Y, {
|
||||
label: config.study_name || 'Model',
|
||||
filePath: config.model?.path,
|
||||
fileType: config.model?.type as 'prt' | 'fem' | 'sim' | undefined,
|
||||
});
|
||||
|
||||
// 2. Solver node
|
||||
const solverType = config.solver?.solution ? `SOL${config.solver.solution}` : undefined;
|
||||
const solverType = config.solver?.solution ? `SOL${config.solver.solution}` : config.solver?.type;
|
||||
const solverId = createNode('solver', COLS.solver, START_Y, {
|
||||
label: 'Solver',
|
||||
label: solverType || 'Solver',
|
||||
solverType: solverType as any,
|
||||
});
|
||||
edges.push({ id: `e_model_solver`, source: modelId, target: solverId });
|
||||
|
||||
// 3. Design variables (column 0, below model)
|
||||
let dvRow = 1;
|
||||
for (const dv of config.design_variables || []) {
|
||||
const dvId = createNode('designVar', COLS.modelDvar, START_Y + dvRow * ROW_HEIGHT, {
|
||||
// 3. Design variables - use min/max (actual format), fallback to lower/upper (legacy)
|
||||
let dvRow = 0;
|
||||
const enabledDvs = (config.design_variables || []).filter(dv => dv.enabled !== false);
|
||||
for (const dv of enabledDvs) {
|
||||
const minVal = dv.min ?? dv.lower ?? 0;
|
||||
const maxVal = dv.max ?? dv.upper ?? 1;
|
||||
const dvId = createNode('designVar', COLS.designVar, START_Y + dvRow * ROW_HEIGHT, {
|
||||
label: dv.expression_name || dv.name,
|
||||
expressionName: dv.expression_name || dv.name,
|
||||
minValue: dv.lower,
|
||||
maxValue: dv.upper,
|
||||
minValue: minVal,
|
||||
maxValue: maxVal,
|
||||
baseline: dv.baseline,
|
||||
unit: dv.units,
|
||||
enabled: dv.enabled ?? true,
|
||||
notes: dv.notes,
|
||||
});
|
||||
edges.push({ id: `e_dv_${dvRow}_model`, source: dvId, target: modelId });
|
||||
dvRow++;
|
||||
}
|
||||
|
||||
// 4. Extractors - infer from objectives and constraints
|
||||
// 4. Extractors - create from extraction_method if available
|
||||
const extractorNames: Record<string, string> = {
|
||||
'E1': 'Displacement', 'E2': 'Frequency', 'E3': 'Solid Stress',
|
||||
'E4': 'BDF Mass', 'E5': 'CAD Mass', 'E8': 'Zernike (OP2)',
|
||||
'E9': 'Zernike (CSV)', 'E10': 'Zernike (RMS)',
|
||||
'zernike_opd': 'Zernike OPD',
|
||||
};
|
||||
|
||||
const extractorIds = new Set<string>();
|
||||
for (const obj of config.objectives || []) {
|
||||
if (obj.extractor) extractorIds.add(obj.extractor);
|
||||
}
|
||||
for (const con of config.constraints || []) {
|
||||
if (con.extractor) extractorIds.add(con.extractor);
|
||||
}
|
||||
|
||||
// If no extractors found, add a default based on objectives
|
||||
if (extractorIds.size === 0 && (config.objectives?.length || 0) > 0) {
|
||||
extractorIds.add('E5'); // Default to CAD Mass
|
||||
}
|
||||
|
||||
let extRow = 0;
|
||||
const extractorMap: Record<string, string> = {};
|
||||
for (const extId of extractorIds) {
|
||||
const nodeId = createNode('extractor', COLS.extractor, START_Y + extRow * ROW_HEIGHT, {
|
||||
label: extractorNames[extId] || extId,
|
||||
extractorId: extId,
|
||||
extractorName: extractorNames[extId] || extId,
|
||||
const extractorNodeIds: string[] = [];
|
||||
|
||||
// Check for extraction_method (Zernike configs)
|
||||
if (config.extraction_method) {
|
||||
const extType = config.extraction_method.type || 'zernike_opd';
|
||||
const zernikeSettings = config.zernike_settings || {};
|
||||
|
||||
const extId = createNode('extractor', COLS.extractor, START_Y + extRow * ROW_HEIGHT, {
|
||||
label: extractorNames[extType] || config.extraction_method.class || 'Extractor',
|
||||
extractorId: extType === 'zernike_opd' ? 'E8' : extType,
|
||||
extractorName: extractorNames[extType] || extType,
|
||||
extractorType: extType,
|
||||
extractMethod: config.extraction_method.method,
|
||||
innerRadius: config.extraction_method.inner_radius,
|
||||
nModes: zernikeSettings.n_modes,
|
||||
subcases: zernikeSettings.subcases,
|
||||
config: {
|
||||
innerRadius: config.extraction_method.inner_radius,
|
||||
outerRadius: config.extraction_method.outer_radius,
|
||||
nModes: zernikeSettings.n_modes,
|
||||
filterLowOrders: zernikeSettings.filter_low_orders,
|
||||
subcases: zernikeSettings.subcases,
|
||||
subcaseLabels: zernikeSettings.subcase_labels,
|
||||
referenceSubcase: zernikeSettings.reference_subcase,
|
||||
extractMethod: config.extraction_method.method,
|
||||
},
|
||||
// Output names from objectives that use this extractor
|
||||
outputNames: config.objectives?.map(o => o.name) || [],
|
||||
});
|
||||
extractorMap[extId] = nodeId;
|
||||
edges.push({ id: `e_solver_ext_${extRow}`, source: solverId, target: nodeId });
|
||||
extractorNodeIds.push(extId);
|
||||
edges.push({ id: `e_solver_ext_${extRow}`, source: solverId, target: extId });
|
||||
extRow++;
|
||||
} else {
|
||||
// Fallback: infer extractors from objectives
|
||||
const extractorIds = new Set<string>();
|
||||
for (const obj of config.objectives || []) {
|
||||
if (obj.extractor) extractorIds.add(obj.extractor);
|
||||
}
|
||||
for (const con of config.constraints || []) {
|
||||
if (con.extractor) extractorIds.add(con.extractor);
|
||||
}
|
||||
if (extractorIds.size === 0 && (config.objectives?.length || 0) > 0) {
|
||||
extractorIds.add('E5'); // Default
|
||||
}
|
||||
|
||||
for (const extId of extractorIds) {
|
||||
const nodeId = createNode('extractor', COLS.extractor, START_Y + extRow * ROW_HEIGHT, {
|
||||
label: extractorNames[extId] || extId,
|
||||
extractorId: extId,
|
||||
extractorName: extractorNames[extId] || extId,
|
||||
});
|
||||
extractorNodeIds.push(nodeId);
|
||||
edges.push({ id: `e_solver_ext_${extRow}`, source: solverId, target: nodeId });
|
||||
extRow++;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Objectives
|
||||
@@ -439,18 +521,34 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
name: obj.name,
|
||||
direction: (obj.direction as 'minimize' | 'maximize') || 'minimize',
|
||||
weight: obj.weight || 1,
|
||||
penaltyWeight: obj.penalty_weight,
|
||||
});
|
||||
objIds.push(objId);
|
||||
|
||||
// Connect to extractor
|
||||
const extNodeId = obj.extractor ? extractorMap[obj.extractor] : Object.values(extractorMap)[0];
|
||||
if (extNodeId) {
|
||||
edges.push({ id: `e_ext_obj_${objRow}`, source: extNodeId, target: objId });
|
||||
// Connect to first extractor (or specific if mapped)
|
||||
if (extractorNodeIds.length > 0) {
|
||||
edges.push({ id: `e_ext_obj_${objRow}`, source: extractorNodeIds[0], target: objId });
|
||||
}
|
||||
objRow++;
|
||||
}
|
||||
|
||||
// 6. Constraints
|
||||
// 6. Hard constraints (converted to objectives with penalties)
|
||||
for (const hc of config.hard_constraints || []) {
|
||||
const hcId = createNode('objective', COLS.objCon, START_Y + objRow * ROW_HEIGHT, {
|
||||
label: `${hc.name} (constraint)`,
|
||||
name: hc.name,
|
||||
direction: 'minimize',
|
||||
weight: hc.penalty_weight,
|
||||
penaltyWeight: hc.penalty_weight,
|
||||
});
|
||||
objIds.push(hcId);
|
||||
if (extractorNodeIds.length > 0) {
|
||||
edges.push({ id: `e_ext_hc_${objRow}`, source: extractorNodeIds[0], target: hcId });
|
||||
}
|
||||
objRow++;
|
||||
}
|
||||
|
||||
// 7. Regular constraints
|
||||
let conRow = objRow;
|
||||
const conIds: string[] = [];
|
||||
for (const con of config.constraints || []) {
|
||||
@@ -461,22 +559,21 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
value: con.value || 0,
|
||||
});
|
||||
conIds.push(conId);
|
||||
|
||||
// Connect to extractor
|
||||
const extNodeId = con.extractor ? extractorMap[con.extractor] : Object.values(extractorMap)[0];
|
||||
if (extNodeId) {
|
||||
edges.push({ id: `e_ext_con_${conRow}`, source: extNodeId, target: conId });
|
||||
if (extractorNodeIds.length > 0) {
|
||||
edges.push({ id: `e_ext_con_${conRow}`, source: extractorNodeIds[0], target: conId });
|
||||
}
|
||||
conRow++;
|
||||
}
|
||||
|
||||
// 7. Algorithm node
|
||||
const method = config.method || (config as any).optimization?.sampler || 'TPE';
|
||||
const maxTrials = config.max_trials || (config as any).optimization?.n_trials || 100;
|
||||
// 8. Algorithm node
|
||||
const method = config.method || config.optimization?.sampler || 'TPE';
|
||||
const maxTrials = config.max_trials || config.optimization?.n_trials || 100;
|
||||
const algoId = createNode('algorithm', COLS.algo, START_Y, {
|
||||
label: 'Algorithm',
|
||||
label: method,
|
||||
method: method as any,
|
||||
maxTrials: maxTrials,
|
||||
sigma0: config.optimization?.sigma0,
|
||||
restartStrategy: config.optimization?.restart_strategy as any,
|
||||
});
|
||||
|
||||
// Connect objectives to algorithm
|
||||
@@ -488,7 +585,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
edges.push({ id: `e_con_${i}_algo`, source: conIds[i], target: algoId });
|
||||
}
|
||||
|
||||
// 8. Surrogate node (if enabled)
|
||||
// 9. Surrogate node (if enabled)
|
||||
if (config.surrogate) {
|
||||
const surId = createNode('surrogate', COLS.surrogate, START_Y, {
|
||||
label: 'Surrogate',
|
||||
|
||||
@@ -4,10 +4,18 @@ import { ToolCall } from '../components/chat/ToolCallCard';
|
||||
|
||||
export type ChatMode = 'user' | 'power';
|
||||
|
||||
export interface CanvasState {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
studyName?: string;
|
||||
studyPath?: string;
|
||||
}
|
||||
|
||||
interface UseChatOptions {
|
||||
studyId?: string | null;
|
||||
mode?: ChatMode;
|
||||
useWebSocket?: boolean;
|
||||
canvasState?: CanvasState | null;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +33,7 @@ export function useChat({
|
||||
studyId,
|
||||
mode = 'user',
|
||||
useWebSocket = true,
|
||||
canvasState: initialCanvasState,
|
||||
onError,
|
||||
}: UseChatOptions = {}) {
|
||||
const [state, setState] = useState<ChatState>({
|
||||
@@ -37,6 +46,9 @@ export function useChat({
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
// Track canvas state for sending with messages
|
||||
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -196,6 +208,10 @@ export function useChat({
|
||||
// Study context was updated - could show notification
|
||||
break;
|
||||
|
||||
case 'canvas_updated':
|
||||
// Canvas state was updated - could show notification
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response - ignore
|
||||
break;
|
||||
@@ -283,11 +299,12 @@ export function useChat({
|
||||
currentMessageRef.current = '';
|
||||
currentToolCallsRef.current = [];
|
||||
|
||||
// Send message via WebSocket
|
||||
// Send message via WebSocket with canvas state
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
content: content.trim(),
|
||||
canvas_state: canvasStateRef.current || undefined,
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -430,6 +447,21 @@ export function useChat({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update canvas state (call this when canvas changes)
|
||||
const updateCanvasState = useCallback((newCanvasState: CanvasState | null) => {
|
||||
canvasStateRef.current = newCanvasState;
|
||||
|
||||
// Also send to backend to update context
|
||||
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'set_canvas',
|
||||
canvas_state: newCanvasState,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [useWebSocket]);
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
isThinking: state.isThinking,
|
||||
@@ -442,5 +474,6 @@ export function useChat({
|
||||
clearMessages,
|
||||
cancelRequest,
|
||||
switchMode,
|
||||
updateCanvasState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,14 +34,60 @@ export interface DesignVarNodeData extends BaseNodeData {
|
||||
expressionName?: string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
baseline?: number;
|
||||
unit?: string;
|
||||
enabled?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Extractor-specific config types
|
||||
export interface ZernikeConfig {
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
nModes?: number;
|
||||
filterLowOrders?: number;
|
||||
subcases?: string[];
|
||||
subcaseLabels?: Record<string, string>;
|
||||
referenceSubcase?: string;
|
||||
extractMethod?: 'extract_relative' | 'extract_rms' | 'extract_absolute';
|
||||
}
|
||||
|
||||
export interface DisplacementConfig {
|
||||
subcase?: number;
|
||||
nodeSet?: string;
|
||||
component?: 'magnitude' | 'x' | 'y' | 'z';
|
||||
}
|
||||
|
||||
export interface StressConfig {
|
||||
subcase?: number;
|
||||
elementSet?: string;
|
||||
stressType?: 'vonMises' | 'principal' | 'max_shear';
|
||||
}
|
||||
|
||||
export interface MassConfig {
|
||||
source?: 'bdf' | 'expression';
|
||||
expressionName?: string;
|
||||
}
|
||||
|
||||
export interface FrequencyConfig {
|
||||
modeNumber?: number;
|
||||
}
|
||||
|
||||
export type ExtractorConfig = Record<string, unknown>;
|
||||
|
||||
export interface ExtractorNodeData extends BaseNodeData {
|
||||
type: 'extractor';
|
||||
extractorId?: string;
|
||||
extractorName?: string;
|
||||
config?: Record<string, unknown>;
|
||||
extractorType?: 'zernike_opd' | 'displacement' | 'stress' | 'mass' | 'frequency';
|
||||
extractMethod?: string;
|
||||
config?: ExtractorConfig;
|
||||
// Zernike-specific (for quick access)
|
||||
innerRadius?: number;
|
||||
nModes?: number;
|
||||
subcases?: string[];
|
||||
// Output mapping
|
||||
outputNames?: string[];
|
||||
}
|
||||
|
||||
export interface ObjectiveNodeData extends BaseNodeData {
|
||||
@@ -49,6 +95,9 @@ export interface ObjectiveNodeData extends BaseNodeData {
|
||||
name?: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
weight?: number;
|
||||
extractorRef?: string; // Reference to extractor ID
|
||||
outputName?: string; // Which output from the extractor
|
||||
penaltyWeight?: number; // For hard constraints (penalty method)
|
||||
}
|
||||
|
||||
export interface ConstraintNodeData extends BaseNodeData {
|
||||
@@ -62,6 +111,11 @@ export interface AlgorithmNodeData extends BaseNodeData {
|
||||
type: 'algorithm';
|
||||
method?: 'TPE' | 'CMA-ES' | 'NSGA-II' | 'GP-BO' | 'RandomSearch';
|
||||
maxTrials?: number;
|
||||
// CMA-ES specific
|
||||
sigma0?: number;
|
||||
restartStrategy?: 'none' | 'ipop' | 'bipop';
|
||||
// Weight settings for multi-objective
|
||||
objectiveWeights?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SurrogateNodeData extends BaseNodeData {
|
||||
|
||||
Reference in New Issue
Block a user