feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
598
dexto/packages/webui/components/AddCustomServerModal.tsx
Normal file
598
dexto/packages/webui/components/AddCustomServerModal.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ServerRegistryEntry } from '@dexto/registry';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Plus, Save } from 'lucide-react';
|
||||
import { KeyValueEditor } from './ui/key-value-editor';
|
||||
|
||||
interface HeaderPair {
|
||||
key: string;
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface AddCustomServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddServer: (
|
||||
entry: Omit<ServerRegistryEntry, 'id' | 'isOfficial' | 'lastUpdated'>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AddCustomServerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddServer,
|
||||
}: AddCustomServerModalProps) {
|
||||
const [formData, setFormData] = useState<{
|
||||
name: string;
|
||||
description: string;
|
||||
category:
|
||||
| 'productivity'
|
||||
| 'development'
|
||||
| 'research'
|
||||
| 'creative'
|
||||
| 'data'
|
||||
| 'communication'
|
||||
| 'custom';
|
||||
icon: string;
|
||||
version: string;
|
||||
author: string;
|
||||
homepage: string;
|
||||
config: {
|
||||
type: 'stdio' | 'sse' | 'http';
|
||||
command: string;
|
||||
args: string[];
|
||||
url: string;
|
||||
env: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
};
|
||||
tags: string[];
|
||||
isInstalled: boolean;
|
||||
requirements: {
|
||||
platform: 'win32' | 'darwin' | 'linux' | 'all';
|
||||
node: string;
|
||||
python: string;
|
||||
dependencies: string[];
|
||||
};
|
||||
}>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'custom',
|
||||
icon: '',
|
||||
version: '',
|
||||
author: '',
|
||||
homepage: '',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
url: '',
|
||||
env: {},
|
||||
headers: {},
|
||||
timeout: 30000,
|
||||
},
|
||||
tags: [],
|
||||
isInstalled: false,
|
||||
requirements: {
|
||||
platform: 'all',
|
||||
node: '',
|
||||
python: '',
|
||||
dependencies: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [argsInput, setArgsInput] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [envInput, setEnvInput] = useState('');
|
||||
const [headerPairs, setHeaderPairs] = useState<HeaderPair[]>([]);
|
||||
const [dependenciesInput, setDependenciesInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'productivity', label: 'Productivity' },
|
||||
{ value: 'development', label: 'Development' },
|
||||
{ value: 'research', label: 'Research' },
|
||||
{ value: 'creative', label: 'Creative' },
|
||||
{ value: 'data', label: 'Data' },
|
||||
{ value: 'communication', label: 'Communication' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
const platforms = [
|
||||
{ value: 'all', label: 'All Platforms' },
|
||||
{ value: 'win32', label: 'Windows' },
|
||||
{ value: 'darwin', label: 'macOS' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Parse inputs
|
||||
const args = argsInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const dependencies = dependenciesInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Parse environment variables
|
||||
const env: Record<string, string> = {};
|
||||
if (envInput.trim()) {
|
||||
const envLines = envInput.split('\n');
|
||||
for (const line of envLines) {
|
||||
// Skip empty lines
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split only at the first '=' character
|
||||
const equalIndex = trimmedLine.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
// Key must exist (equalIndex > 0, not >= 0)
|
||||
const key = trimmedLine.substring(0, equalIndex).trim();
|
||||
const value = trimmedLine.substring(equalIndex + 1).trim();
|
||||
|
||||
// Only add if key is not empty
|
||||
if (key) {
|
||||
env[key] = value; // Value can be empty string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert header pairs to record
|
||||
const headers: Record<string, string> = {};
|
||||
headerPairs.forEach((pair) => {
|
||||
if (pair.key.trim() && pair.value.trim()) {
|
||||
headers[pair.key.trim()] = pair.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.name.trim()) {
|
||||
throw new Error('Server name is required');
|
||||
}
|
||||
if (!formData.description.trim()) {
|
||||
throw new Error('Description is required');
|
||||
}
|
||||
if (formData.config.type === 'stdio' && !formData.config.command.trim()) {
|
||||
throw new Error('Command is required for stdio servers');
|
||||
}
|
||||
if (formData.config.type === 'sse' && !formData.config.url.trim()) {
|
||||
throw new Error('URL is required for SSE servers');
|
||||
}
|
||||
if (formData.config.type === 'http' && !formData.config.url.trim()) {
|
||||
throw new Error('URL is required for HTTP servers');
|
||||
}
|
||||
|
||||
const entry: Omit<ServerRegistryEntry, 'id' | 'isOfficial' | 'lastUpdated'> = {
|
||||
...formData,
|
||||
config: {
|
||||
...formData.config,
|
||||
args,
|
||||
env,
|
||||
headers,
|
||||
},
|
||||
tags,
|
||||
requirements: {
|
||||
...formData.requirements,
|
||||
dependencies,
|
||||
},
|
||||
};
|
||||
|
||||
await onAddServer(entry);
|
||||
onClose();
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'custom',
|
||||
icon: '',
|
||||
version: '',
|
||||
author: '',
|
||||
homepage: '',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
url: '',
|
||||
env: {},
|
||||
headers: {},
|
||||
timeout: 30000,
|
||||
},
|
||||
tags: [],
|
||||
isInstalled: false,
|
||||
requirements: {
|
||||
platform: 'all',
|
||||
node: '',
|
||||
python: '',
|
||||
dependencies: [],
|
||||
},
|
||||
});
|
||||
setArgsInput('');
|
||||
setTagsInput('');
|
||||
setEnvInput('');
|
||||
setHeaderPairs([]);
|
||||
setDependenciesInput('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add custom server');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Add Custom Server to Registry
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add your own custom MCP server configuration to the registry for easy reuse.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Server Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Custom Server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value: any) =>
|
||||
setFormData((prev) => ({ ...prev, category: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="Describe what this server does..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Server Configuration</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="serverType">Server Type</Label>
|
||||
<Select
|
||||
value={formData.config.type}
|
||||
onValueChange={(value: 'stdio' | 'sse' | 'http') =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: { ...prev.config, type: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="serverType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.config.type === 'stdio' ? (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="command">Command *</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={formData.config.command}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: { ...prev.config, command: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="npx or python"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="args">Arguments</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={argsInput}
|
||||
onChange={(e) => setArgsInput(e.target.value)}
|
||||
placeholder="Comma-separated: -m,script.py,--port,8080"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : formData.config.type === 'sse' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
value={formData.config.url}
|
||||
onChange={handleConfigChange}
|
||||
placeholder="http://localhost:8080/sse"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
value={formData.config.url}
|
||||
onChange={handleConfigChange}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.config.type === 'stdio' && (
|
||||
<div>
|
||||
<Label htmlFor="env">Environment Variables</Label>
|
||||
<Textarea
|
||||
id="env"
|
||||
value={envInput}
|
||||
onChange={(e) => setEnvInput(e.target.value)}
|
||||
placeholder={`KEY1=value1\nKEY2=value2`}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.config.type === 'sse' || formData.config.type === 'http') && (
|
||||
<div className="space-y-4">
|
||||
<KeyValueEditor
|
||||
label="Headers"
|
||||
pairs={headerPairs}
|
||||
onChange={setHeaderPairs}
|
||||
placeholder={{
|
||||
key: 'Authorization',
|
||||
value: 'Bearer your-token',
|
||||
}}
|
||||
keyLabel="Header"
|
||||
valueLabel="Value"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="tags">Tags</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="Comma-separated: file, database, api"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="icon">Icon (Emoji)</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={formData.icon}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, icon: e.target.value }))
|
||||
}
|
||||
placeholder="⚡"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, version: e.target.value }))
|
||||
}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="author">Author</Label>
|
||||
<Input
|
||||
id="author"
|
||||
value={formData.author}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, author: e.target.value }))
|
||||
}
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="homepage">Homepage URL</Label>
|
||||
<Input
|
||||
id="homepage"
|
||||
value={formData.homepage}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, homepage: e.target.value }))
|
||||
}
|
||||
placeholder="https://github.com/youruser/yourserver"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Requirements</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="platform">Platform</Label>
|
||||
<Select
|
||||
value={formData.requirements.platform}
|
||||
onValueChange={(value: 'win32' | 'darwin' | 'linux' | 'all') =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
requirements: { ...prev.requirements, platform: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="platform">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms.map((platform) => (
|
||||
<SelectItem key={platform.value} value={platform.value}>
|
||||
{platform.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dependencies">Dependencies</Label>
|
||||
<Input
|
||||
id="dependencies"
|
||||
value={dependenciesInput}
|
||||
onChange={(e) => setDependenciesInput(e.target.value)}
|
||||
placeholder="Comma-separated: package1, package2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="nodeVersion">Node.js Version</Label>
|
||||
<Input
|
||||
id="nodeVersion"
|
||||
value={formData.requirements.node}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
requirements: {
|
||||
...prev.requirements,
|
||||
node: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder=">=16.0.0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pythonVersion">Python Version</Label>
|
||||
<Input
|
||||
id="pythonVersion"
|
||||
value={formData.requirements.python}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
requirements: {
|
||||
...prev.requirements,
|
||||
python: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder=">=3.8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
'Adding...'
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Add to Registry
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AgentConfigEditor
|
||||
*
|
||||
* Monaco-based YAML editor component for editing agent configuration files.
|
||||
* Provides syntax highlighting, line numbers, and configurable editor options.
|
||||
* Validation is handled externally via the onValidate callback.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
|
||||
interface AgentConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidate?: (markers: editor.IMarker[]) => void;
|
||||
readOnly?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function AgentConfigEditor({
|
||||
value,
|
||||
onChange,
|
||||
onValidate,
|
||||
readOnly = false,
|
||||
height = '100%',
|
||||
}: AgentConfigEditorProps) {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up validation when editor is mounted
|
||||
if (editorRef.current && onValidate) {
|
||||
const model = editorRef.current.getModel();
|
||||
if (model) {
|
||||
// Server-side validation is handled via API
|
||||
// Monaco provides basic YAML syntax highlighting
|
||||
}
|
||||
}
|
||||
}, [onValidate]);
|
||||
|
||||
const handleEditorDidMount: OnMount = (editorInstance) => {
|
||||
editorRef.current = editorInstance as editor.IStandaloneCodeEditor;
|
||||
|
||||
// Configure editor options
|
||||
editorInstance.updateOptions({
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderLineHighlight: 'all',
|
||||
folding: true,
|
||||
automaticLayout: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
if (value !== undefined) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="yaml"
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
readOnly,
|
||||
wordWrap: 'on',
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ConfigValidationStatus
|
||||
*
|
||||
* Displays real-time validation status for agent configuration editing.
|
||||
* Shows validation state (validating/valid/invalid), error count, warnings,
|
||||
* and detailed error/warning messages with line numbers. Provides visual
|
||||
* feedback during configuration editing to help users fix issues before saving.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
|
||||
|
||||
interface ConfigValidationStatusProps {
|
||||
isValidating: boolean;
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigValidationStatus({
|
||||
isValidating,
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
hasUnsavedChanges,
|
||||
}: ConfigValidationStatusProps) {
|
||||
return (
|
||||
<div className="border-t border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isValidating ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Validating...</span>
|
||||
</>
|
||||
) : isValid ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Valid configuration
|
||||
{hasUnsavedChanges && ' (unsaved changes)'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
{errors.length} {errors.length === 1 ? 'error' : 'errors'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings indicator */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
<span className="text-sm text-yellow-500">
|
||||
{warnings.length} {warnings.length === 1 ? 'warning' : 'warnings'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error list */}
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
|
||||
{errors.map((error, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-destructive/10 text-destructive rounded px-2 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{error.path && <span className="font-medium">{error.path}: </span>}
|
||||
{error.message}
|
||||
{error.line && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(line {error.line}
|
||||
{error.column && `:${error.column}`})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning list */}
|
||||
{warnings.length > 0 && errors.length === 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
|
||||
{warnings.map((warning, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 rounded px-2 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{warning.path}: </span>
|
||||
{warning.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* CustomizePanel - Parent coordinator for agent configuration editing
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load/save configuration via API
|
||||
* - Mode switching (Form ↔ YAML)
|
||||
* - YAML ↔ Config object conversion
|
||||
* - Unsaved changes detection
|
||||
* - Validation orchestration
|
||||
*
|
||||
* The actual editing is delegated to:
|
||||
* - YAMLEditorView - for YAML mode
|
||||
* - FormEditorView - for Form mode
|
||||
*
|
||||
* TODO: Future optimization - derive form metadata from schemas
|
||||
* Currently form sections have manual field definitions. Consider deriving:
|
||||
* - Required/optional fields from schema
|
||||
* - Default values from schema defaults
|
||||
* - Enum options from schema enums
|
||||
* - Field types from schema types
|
||||
* This would eliminate hardcoded UI metadata and reduce maintenance.
|
||||
* See packages/core/src/utils/schema-metadata.ts for the core utilities that enable this (needs runtime fixes).
|
||||
* This TODO is linked with the corresponding TODO in schema-metadata.ts tracking the same goal.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { Button } from '../ui/button';
|
||||
import { X, Save, RefreshCw, AlertTriangle, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useAgentConfig,
|
||||
useValidateAgent,
|
||||
useSaveAgentConfig,
|
||||
type ValidationError,
|
||||
type ValidationWarning,
|
||||
} from '../hooks/useAgentConfig';
|
||||
import YAMLEditorView from './YAMLEditorView';
|
||||
import FormEditorView from './FormEditorView';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip';
|
||||
import * as yaml from 'yaml';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
|
||||
interface CustomizePanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
variant?: 'overlay' | 'inline';
|
||||
}
|
||||
|
||||
type EditorMode = 'form' | 'yaml';
|
||||
|
||||
export default function CustomizePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
variant = 'overlay',
|
||||
}: CustomizePanelProps) {
|
||||
// TanStack Query hooks
|
||||
const {
|
||||
data: configData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
refetch: refetchConfig,
|
||||
} = useAgentConfig(isOpen);
|
||||
const validateMutation = useValidateAgent();
|
||||
const saveMutation = useSaveAgentConfig();
|
||||
|
||||
// Content state
|
||||
const [yamlContent, setYamlContent] = useState<string>('');
|
||||
const [originalYamlContent, setOriginalYamlContent] = useState<string>('');
|
||||
const [parsedConfig, setParsedConfig] = useState<AgentConfig | null>(null);
|
||||
const [originalParsedConfig, setOriginalParsedConfig] = useState<AgentConfig | null>(null);
|
||||
const [yamlDocument, setYamlDocument] = useState<yaml.Document | null>(null);
|
||||
const [relativePath, setRelativePath] = useState<string>('');
|
||||
|
||||
// Editor mode
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('yaml');
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
// Validation state
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [errors, setErrors] = useState<ValidationError[]>([]);
|
||||
const [warnings, setWarnings] = useState<ValidationWarning[]>([]);
|
||||
|
||||
// Unsaved changes
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||
|
||||
// Save state (for success messages)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string>('');
|
||||
|
||||
// Debounced validation
|
||||
const [debouncedYamlContent] = useDebounce(yamlContent, 500);
|
||||
const latestValidationRequestRef = useRef(0);
|
||||
|
||||
// Validate YAML content via API
|
||||
const validateYaml = useCallback(
|
||||
async (yaml: string) => {
|
||||
const requestId = latestValidationRequestRef.current + 1;
|
||||
latestValidationRequestRef.current = requestId;
|
||||
|
||||
try {
|
||||
const data = await validateMutation.mutateAsync({ yaml });
|
||||
if (latestValidationRequestRef.current === requestId) {
|
||||
setIsValid(data.valid);
|
||||
setErrors(data.errors || []);
|
||||
setWarnings(data.warnings || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`Validation error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
if (latestValidationRequestRef.current === requestId) {
|
||||
setIsValid(false);
|
||||
setErrors([
|
||||
{ message: 'Failed to validate configuration', code: 'VALIDATION_ERROR' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[validateMutation.mutateAsync]
|
||||
);
|
||||
|
||||
// Initialize state when config data loads
|
||||
useEffect(() => {
|
||||
if (configData && isOpen) {
|
||||
setYamlContent(configData.yaml);
|
||||
setOriginalYamlContent(configData.yaml);
|
||||
setRelativePath(configData.relativePath);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Parse for form mode
|
||||
const { config, document } = parseYamlToConfig(configData.yaml);
|
||||
if (config && document) {
|
||||
setParsedConfig(config);
|
||||
setOriginalParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
}
|
||||
|
||||
// Initial validation
|
||||
validateYaml(configData.yaml);
|
||||
}
|
||||
}, [configData, isOpen, validateYaml]);
|
||||
|
||||
// Parse YAML to config object and document
|
||||
const parseYamlToConfig = (
|
||||
yamlString: string
|
||||
): { config: AgentConfig | null; document: yaml.Document | null; error: string | null } => {
|
||||
console.log('[parseYamlToConfig] Starting parse');
|
||||
try {
|
||||
const document = yaml.parseDocument(yamlString);
|
||||
console.log('[parseYamlToConfig] Document created:', document);
|
||||
|
||||
// Check for parse errors
|
||||
if (document.errors && document.errors.length > 0) {
|
||||
console.debug('[parseYamlToConfig] Parse errors:', document.errors);
|
||||
const message = document.errors.map((e) => e.message).join('; ');
|
||||
return { config: null, document: null, error: message };
|
||||
}
|
||||
|
||||
const config = document.toJS() as AgentConfig;
|
||||
console.log('[parseYamlToConfig] Config parsed successfully:', config);
|
||||
return { config, document, error: null };
|
||||
} catch (err: unknown) {
|
||||
console.debug('[parseYamlToConfig] Exception:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to parse YAML';
|
||||
return { config: null, document: null, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
// Update YAML document from config object while preserving comments
|
||||
const updateYamlDocumentFromConfig = (
|
||||
document: yaml.Document,
|
||||
config: AgentConfig
|
||||
): yaml.Document => {
|
||||
console.log('[updateYamlDocumentFromConfig] Starting update');
|
||||
console.log('[updateYamlDocumentFromConfig] Document:', document);
|
||||
console.log('[updateYamlDocumentFromConfig] Config:', config);
|
||||
|
||||
const updateNode = (node: any, value: any): any => {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Handle arrays - create new sequence
|
||||
if (Array.isArray(value)) {
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Handle objects - update map recursively
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (!node || !node.items) {
|
||||
// Create new map if node doesn't exist
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Update existing map
|
||||
const existingKeys = new Set<string>();
|
||||
|
||||
// Update existing keys and track them
|
||||
for (const pair of node.items) {
|
||||
const key = pair.key.value;
|
||||
existingKeys.add(key);
|
||||
|
||||
if (key in value) {
|
||||
// Update the value while preserving the pair (and its comments)
|
||||
pair.value = updateNode(pair.value, value[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new keys
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (!existingKeys.has(key)) {
|
||||
node.items.push(document.createPair(key, val));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove keys not in new config
|
||||
node.items = node.items.filter((pair: any) => {
|
||||
const key = pair.key.value;
|
||||
return key in value;
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Handle primitives - create new scalar
|
||||
return document.createNode(value);
|
||||
};
|
||||
|
||||
try {
|
||||
// Update the root contents
|
||||
document.contents = updateNode(document.contents, config);
|
||||
console.log('[updateYamlDocumentFromConfig] Update successful');
|
||||
return document;
|
||||
} catch (err) {
|
||||
console.error('[updateYamlDocumentFromConfig] Update failed:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Generic deep cleanup to remove null/undefined/empty values
|
||||
const cleanupConfig = (config: AgentConfig): AgentConfig => {
|
||||
const isEmptyValue = (value: unknown): boolean => {
|
||||
// null and undefined are empty
|
||||
if (value === null || value === undefined) return true;
|
||||
// Empty string is empty
|
||||
if (value === '') return true;
|
||||
// Empty arrays are empty
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
// Empty objects are empty (but not Date, etc)
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.prototype.toString.call(value) === '[object Object]' &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Everything else (including false, 0, etc) is not empty
|
||||
return false;
|
||||
};
|
||||
|
||||
const deepCleanup = (obj: any): any => {
|
||||
if (Array.isArray(obj)) {
|
||||
// For arrays, recursively clean each element and filter out empty ones
|
||||
return obj.map(deepCleanup).filter((item) => !isEmptyValue(item));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const cleaned: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Skip empty values
|
||||
if (isEmptyValue(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively clean objects and arrays
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const cleanedValue = deepCleanup(value);
|
||||
// Only add if the cleaned value is not empty
|
||||
if (!isEmptyValue(cleanedValue)) {
|
||||
cleaned[key] = cleanedValue;
|
||||
}
|
||||
} else {
|
||||
// Keep non-object, non-empty values
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Return primitives as-is
|
||||
return obj;
|
||||
};
|
||||
|
||||
return deepCleanup(config) as AgentConfig;
|
||||
};
|
||||
|
||||
// Serialize config back to YAML while preserving comments
|
||||
const serializeConfigToYaml = (config: AgentConfig, document: yaml.Document): string => {
|
||||
console.log('[serializeConfigToYaml] Starting serialization');
|
||||
console.log('[serializeConfigToYaml] Document:', document);
|
||||
console.log('[serializeConfigToYaml] Config:', config);
|
||||
|
||||
// Clean up config to remove null/undefined optional fields
|
||||
const cleanedConfig = cleanupConfig(config);
|
||||
console.log('[serializeConfigToYaml] Cleaned config:', cleanedConfig);
|
||||
|
||||
// Update document with new config and serialize with comments preserved
|
||||
const updatedDoc = updateYamlDocumentFromConfig(document, cleanedConfig);
|
||||
const result = updatedDoc.toString();
|
||||
console.log('[serializeConfigToYaml] Serialized result length:', result.length);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Deep comparison helper for configs
|
||||
const configsAreEqual = (a: AgentConfig | null, b: AgentConfig | null): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
};
|
||||
|
||||
// Handle YAML editor changes
|
||||
const handleYamlChange = (value: string) => {
|
||||
setYamlContent(value);
|
||||
setHasUnsavedChanges(value !== originalYamlContent);
|
||||
setSaveSuccess(false);
|
||||
|
||||
// Update parsed config and document for potential form mode switch
|
||||
const { config, document } = parseYamlToConfig(value);
|
||||
if (config && document) {
|
||||
setParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
}
|
||||
// Validation happens automatically via debouncedYamlContent useEffect
|
||||
};
|
||||
|
||||
// Handle form editor changes
|
||||
const handleFormChange = (newConfig: AgentConfig) => {
|
||||
console.log('[handleFormChange] Called with new config');
|
||||
console.log('[handleFormChange] yamlDocument exists?', !!yamlDocument);
|
||||
|
||||
if (!yamlDocument) {
|
||||
console.error('[handleFormChange] No document available - this should not happen!');
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedConfig(newConfig);
|
||||
// Use document to preserve comments
|
||||
const newYaml = serializeConfigToYaml(newConfig, yamlDocument);
|
||||
setYamlContent(newYaml);
|
||||
// Use semantic comparison for form mode to handle YAML formatting differences
|
||||
setHasUnsavedChanges(!configsAreEqual(newConfig, originalParsedConfig));
|
||||
setSaveSuccess(false);
|
||||
// Validation happens automatically via debouncedYamlContent useEffect
|
||||
};
|
||||
|
||||
// Handle mode switch
|
||||
const handleModeSwitch = (newMode: EditorMode) => {
|
||||
console.log(
|
||||
'[handleModeSwitch] Called with newMode:',
|
||||
newMode,
|
||||
'current mode:',
|
||||
editorMode
|
||||
);
|
||||
if (newMode === editorMode) {
|
||||
console.log('[handleModeSwitch] Same mode, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === 'form') {
|
||||
console.log('[handleModeSwitch] Switching to form mode, parsing YAML...');
|
||||
// Switching to form mode - ensure config is parsed
|
||||
const { config, document, error } = parseYamlToConfig(yamlContent);
|
||||
console.log('[handleModeSwitch] Parse result:', { config, document, error });
|
||||
if (error) {
|
||||
console.error('[handleModeSwitch] Parse error, not switching:', error);
|
||||
setParseError(error);
|
||||
// Don't switch modes if parsing fails
|
||||
return;
|
||||
}
|
||||
console.log('[handleModeSwitch] Parse successful, setting state');
|
||||
setParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
setParseError(null);
|
||||
}
|
||||
|
||||
console.log('[handleModeSwitch] Setting editor mode to:', newMode);
|
||||
setEditorMode(newMode);
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isValid || errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveSuccess(false);
|
||||
setSaveMessage('');
|
||||
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync({ yaml: yamlContent });
|
||||
|
||||
setOriginalYamlContent(yamlContent);
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveSuccess(true);
|
||||
|
||||
if (data.restarted) {
|
||||
setSaveMessage(
|
||||
`Configuration applied successfully — ${data.changesApplied.join(', ')} updated`
|
||||
);
|
||||
} else {
|
||||
setSaveMessage('Configuration saved successfully (no changes detected)');
|
||||
}
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
setSaveMessage('');
|
||||
}, 5000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error saving agent config: ${message}`);
|
||||
}
|
||||
}, [isValid, errors, saveMutation, yamlContent]);
|
||||
|
||||
// Reload configuration
|
||||
const handleReload = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
refetchConfig();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close with unsaved changes check
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [hasUnsavedChanges, onClose]);
|
||||
|
||||
// Confirm discard changes
|
||||
const handleDiscardChanges = () => {
|
||||
setShowUnsavedDialog(false);
|
||||
setYamlContent(originalYamlContent);
|
||||
// Also reset parsed config for form mode
|
||||
if (originalParsedConfig) {
|
||||
setParsedConfig(originalParsedConfig);
|
||||
// Re-parse document for comment preservation
|
||||
const { document } = parseYamlToConfig(originalYamlContent);
|
||||
if (document) {
|
||||
setYamlDocument(document);
|
||||
}
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
refetchConfig();
|
||||
};
|
||||
|
||||
// Config loads automatically via useAgentConfig hook when isOpen is true
|
||||
|
||||
// Trigger validation when debounced content changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
validateYaml(debouncedYamlContent);
|
||||
}
|
||||
}, [debouncedYamlContent, isOpen, validateYaml]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+S / Ctrl+S to save
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!saveMutation.isPending && isValid) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
// Escape to close
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, saveMutation.isPending, isValid, hasUnsavedChanges, handleSave, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Calculate save button disabled reason
|
||||
const getSaveDisabledReason = (): string | null => {
|
||||
if (saveMutation.isPending) return null; // Not really disabled, just in progress
|
||||
if (!hasUnsavedChanges) return 'No changes to save';
|
||||
if (errors.length > 0) {
|
||||
// Find the most relevant error
|
||||
const firstError = errors[0];
|
||||
if (firstError.path) {
|
||||
return `Configuration error in ${firstError.path}: ${firstError.message}`;
|
||||
}
|
||||
return `Configuration error: ${firstError.message}`;
|
||||
}
|
||||
if (!isValid) return 'Configuration has validation errors';
|
||||
return null;
|
||||
};
|
||||
|
||||
const saveDisabledReason = getSaveDisabledReason();
|
||||
const isSaveDisabled =
|
||||
!hasUnsavedChanges || saveMutation.isPending || !isValid || errors.length > 0;
|
||||
|
||||
const panelContent = (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Customize Agent</h2>
|
||||
<a
|
||||
href="https://docs.dexto.ai/docs/guides/configuring-dexto/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
title="View configuration documentation"
|
||||
>
|
||||
View docs
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{relativePath && (
|
||||
<p className="text-xs text-muted-foreground">{relativePath}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center gap-1 bg-muted/50 rounded-md p-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={editorMode === 'yaml' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleModeSwitch('yaml')}
|
||||
className="h-7 px-3"
|
||||
>
|
||||
YAML Editor
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Edit configuration in raw YAML format with full control
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={editorMode === 'form' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleModeSwitch('form')}
|
||||
className="h-7 px-3"
|
||||
>
|
||||
Form Editor
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Edit configuration using user-friendly forms
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReload}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reload configuration</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load configuration
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{loadError?.message || 'Unknown error'}
|
||||
</p>
|
||||
<Button onClick={() => refetchConfig()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading configuration...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : parseError && editorMode === 'form' ? (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Cannot parse YAML</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{parseError}</p>
|
||||
<Button onClick={() => setEditorMode('yaml')} variant="outline">
|
||||
Switch to YAML Editor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : editorMode === 'yaml' ? (
|
||||
<YAMLEditorView
|
||||
value={yamlContent}
|
||||
onChange={handleYamlChange}
|
||||
isValidating={validateMutation.isPending}
|
||||
isValid={isValid}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
) : parsedConfig ? (
|
||||
<FormEditorView
|
||||
config={parsedConfig}
|
||||
onChange={handleFormChange}
|
||||
errors={errors.reduce(
|
||||
(acc, err) => {
|
||||
if (err.path) {
|
||||
acc[err.path] = err.message;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!loadError && !isLoading && (
|
||||
<div className="flex flex-col border-t border-border">
|
||||
{/* Save status messages */}
|
||||
{(saveSuccess || saveMutation.error) && (
|
||||
<div className="px-4 py-3 bg-muted/50 border-b border-border">
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-500">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>{saveMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveMutation.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{saveMutation.error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{saveDisabledReason || 'Save configuration (⌘S)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved changes dialog */}
|
||||
<Dialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes. Do you want to discard them?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowUnsavedDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
return panelContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity duration-300',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-50 w-full sm:w-[600px] md:w-[700px] lg:w-[800px] border-l border-border/50 bg-card/95 backdrop-blur-xl shadow-2xl transform transition-transform duration-300',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
274
dexto/packages/webui/components/AgentEditor/FormEditor.tsx
Normal file
274
dexto/packages/webui/components/AgentEditor/FormEditor.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LLMConfigSection } from './form-sections/LLMConfigSection';
|
||||
import { SystemPromptSection } from './form-sections/SystemPromptSection';
|
||||
import { McpServersSection } from './form-sections/McpServersSection';
|
||||
import { StorageSection } from './form-sections/StorageSection';
|
||||
import { ToolConfirmationSection } from './form-sections/ToolConfirmationSection';
|
||||
import { Collapsible } from '../ui/collapsible';
|
||||
import { Input } from '../ui/input';
|
||||
import { LabelWithTooltip } from '../ui/label-with-tooltip';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { AgentConfig, ContributorConfig } from '@dexto/core';
|
||||
|
||||
interface FormEditorProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
type SectionKey = 'basic' | 'llm' | 'systemPrompt' | 'mcpServers' | 'storage' | 'toolConfirmation';
|
||||
|
||||
export default function FormEditor({ config, onChange, errors = {} }: FormEditorProps) {
|
||||
// Convert systemPrompt to contributors format for the UI
|
||||
const systemPromptValue = (() => {
|
||||
if (!config.systemPrompt) {
|
||||
return { contributors: [] };
|
||||
}
|
||||
if (typeof config.systemPrompt === 'string') {
|
||||
// Convert string to contributors array
|
||||
return {
|
||||
contributors: [
|
||||
{
|
||||
id: 'primary',
|
||||
type: 'static' as const,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content: config.systemPrompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// Already in object format with contributors - ensure contributors array exists
|
||||
return {
|
||||
contributors: config.systemPrompt.contributors || [],
|
||||
};
|
||||
})();
|
||||
|
||||
// Track which sections are open
|
||||
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
|
||||
basic: true,
|
||||
llm: false,
|
||||
systemPrompt: false,
|
||||
mcpServers: false,
|
||||
storage: false,
|
||||
toolConfirmation: false,
|
||||
});
|
||||
|
||||
// Map errors to sections
|
||||
const sectionErrors = mapErrorsToSections(errors);
|
||||
|
||||
// Auto-expand sections with errors
|
||||
useEffect(() => {
|
||||
// Compute derived value inside effect to avoid stale closures
|
||||
const derivedSectionErrors = mapErrorsToSections(errors);
|
||||
const sectionsWithErrors = Object.keys(derivedSectionErrors).filter(
|
||||
(section) => derivedSectionErrors[section as SectionKey].length > 0
|
||||
) as SectionKey[];
|
||||
|
||||
if (sectionsWithErrors.length > 0) {
|
||||
setOpenSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
sectionsWithErrors.forEach((section) => {
|
||||
updated[section] = true;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const toggleSection = (section: SectionKey) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle section updates
|
||||
const updateLLM = (llm: AgentConfig['llm']) => {
|
||||
onChange({ ...config, llm });
|
||||
};
|
||||
|
||||
const updateSystemPrompt = (value: { contributors: ContributorConfig[] }) => {
|
||||
onChange({ ...config, systemPrompt: value });
|
||||
};
|
||||
|
||||
const updateMcpServers = (mcpServers: AgentConfig['mcpServers']) => {
|
||||
onChange({ ...config, mcpServers });
|
||||
};
|
||||
|
||||
const updateStorage = (storage: AgentConfig['storage']) => {
|
||||
onChange({ ...config, storage });
|
||||
};
|
||||
|
||||
const updateToolConfirmation = (toolConfirmation: AgentConfig['toolConfirmation']) => {
|
||||
onChange({ ...config, toolConfirmation });
|
||||
};
|
||||
|
||||
// Check if config has advanced features that aren't supported in form mode
|
||||
const hasAdvancedFeatures = checkForAdvancedFeatures(config);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* Advanced Features Warning */}
|
||||
{hasAdvancedFeatures && (
|
||||
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-500">
|
||||
Advanced Configuration Detected
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600/80 dark:text-yellow-500/80 mt-1">
|
||||
Some advanced features may not be editable in form mode. Switch to
|
||||
YAML editor for full control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Sections */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Basic Info Section */}
|
||||
<Collapsible
|
||||
title="Basic Information"
|
||||
open={openSections.basic}
|
||||
onOpenChange={() => toggleSection('basic')}
|
||||
errorCount={sectionErrors.basic.length}
|
||||
sectionErrors={sectionErrors.basic}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<LabelWithTooltip
|
||||
htmlFor="agent-greeting"
|
||||
tooltip="The initial message shown to users when they start a conversation"
|
||||
>
|
||||
Greeting Message
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="agent-greeting"
|
||||
value={config.greeting || ''}
|
||||
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
|
||||
placeholder="Hello! How can I help you today?"
|
||||
aria-invalid={!!errors.greeting}
|
||||
/>
|
||||
{errors.greeting && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.greeting}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
<LLMConfigSection
|
||||
value={config.llm}
|
||||
onChange={updateLLM}
|
||||
errors={errors}
|
||||
open={openSections.llm}
|
||||
onOpenChange={() => toggleSection('llm')}
|
||||
errorCount={sectionErrors.llm.length}
|
||||
sectionErrors={sectionErrors.llm}
|
||||
/>
|
||||
|
||||
{/* System Prompt */}
|
||||
<SystemPromptSection
|
||||
value={systemPromptValue}
|
||||
onChange={updateSystemPrompt}
|
||||
errors={errors}
|
||||
open={openSections.systemPrompt}
|
||||
onOpenChange={() => toggleSection('systemPrompt')}
|
||||
errorCount={sectionErrors.systemPrompt.length}
|
||||
sectionErrors={sectionErrors.systemPrompt}
|
||||
/>
|
||||
|
||||
{/* MCP Servers */}
|
||||
<McpServersSection
|
||||
value={config.mcpServers || {}}
|
||||
onChange={updateMcpServers}
|
||||
errors={errors}
|
||||
open={openSections.mcpServers}
|
||||
onOpenChange={() => toggleSection('mcpServers')}
|
||||
errorCount={sectionErrors.mcpServers.length}
|
||||
sectionErrors={sectionErrors.mcpServers}
|
||||
/>
|
||||
|
||||
{/* Storage Configuration */}
|
||||
<StorageSection
|
||||
value={
|
||||
config.storage || {
|
||||
cache: { type: 'in-memory' },
|
||||
database: { type: 'in-memory' },
|
||||
blob: { type: 'local', storePath: '/tmp/dexto-blobs' },
|
||||
}
|
||||
}
|
||||
onChange={updateStorage}
|
||||
errors={errors}
|
||||
open={openSections.storage}
|
||||
onOpenChange={() => toggleSection('storage')}
|
||||
errorCount={sectionErrors.storage.length}
|
||||
sectionErrors={sectionErrors.storage}
|
||||
/>
|
||||
|
||||
{/* Tool Confirmation */}
|
||||
<ToolConfirmationSection
|
||||
value={config.toolConfirmation || {}}
|
||||
onChange={updateToolConfirmation}
|
||||
errors={errors}
|
||||
open={openSections.toolConfirmation}
|
||||
onOpenChange={() => toggleSection('toolConfirmation')}
|
||||
errorCount={sectionErrors.toolConfirmation.length}
|
||||
sectionErrors={sectionErrors.toolConfirmation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config has advanced features that aren't well-supported in form mode
|
||||
*/
|
||||
function checkForAdvancedFeatures(config: AgentConfig): boolean {
|
||||
// System prompt is now fully supported in form mode via contributors
|
||||
|
||||
// Check for session config customization
|
||||
if (config.sessions && Object.keys(config.sessions).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for internal tools customization
|
||||
if (config.internalTools) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error paths to form sections
|
||||
*/
|
||||
function mapErrorsToSections(errors: Record<string, string>): Record<SectionKey, string[]> {
|
||||
const sectionErrors: Record<SectionKey, string[]> = {
|
||||
basic: [],
|
||||
llm: [],
|
||||
systemPrompt: [],
|
||||
mcpServers: [],
|
||||
storage: [],
|
||||
toolConfirmation: [],
|
||||
};
|
||||
|
||||
Object.entries(errors).forEach(([path, message]) => {
|
||||
if (path === 'greeting') {
|
||||
sectionErrors.basic.push(message);
|
||||
} else if (path.startsWith('llm.')) {
|
||||
sectionErrors.llm.push(message);
|
||||
} else if (path.startsWith('systemPrompt')) {
|
||||
sectionErrors.systemPrompt.push(message);
|
||||
} else if (path.startsWith('mcpServers')) {
|
||||
sectionErrors.mcpServers.push(message);
|
||||
} else if (path.startsWith('storage.')) {
|
||||
sectionErrors.storage.push(message);
|
||||
} else if (path.startsWith('toolConfirmation.')) {
|
||||
sectionErrors.toolConfirmation.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
return sectionErrors;
|
||||
}
|
||||
766
dexto/packages/webui/components/AgentEditor/FormEditorTabs.tsx
Normal file
766
dexto/packages/webui/components/AgentEditor/FormEditorTabs.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* FormEditorTabs - Clean tabbed form editor for agent configuration
|
||||
*
|
||||
* Design follows session/server panel patterns:
|
||||
* - Minimal borders, spacing-based hierarchy
|
||||
* - Section headers as uppercase labels
|
||||
* - shadcn Select components
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Button } from '../ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import {
|
||||
Settings,
|
||||
Brain,
|
||||
Wrench,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import type { AgentConfig, ContributorConfig } from '@dexto/core';
|
||||
import { LLM_PROVIDERS, MCP_SERVER_TYPES } from '@dexto/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDiscovery } from '../hooks/useDiscovery';
|
||||
import { useLLMCatalog } from '../hooks/useLLM';
|
||||
|
||||
// Providers that support custom baseURL
|
||||
const BASE_URL_PROVIDERS = ['openai-compatible', 'litellm'];
|
||||
|
||||
interface FormEditorTabsProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabValue = 'model' | 'behavior' | 'tools';
|
||||
|
||||
export default function FormEditorTabs({ config, onChange, errors = {} }: FormEditorTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('model');
|
||||
|
||||
// Count errors per tab
|
||||
const modelErrors = Object.keys(errors).filter(
|
||||
(k) => k.startsWith('llm.') || k === 'greeting'
|
||||
).length;
|
||||
const behaviorErrors = Object.keys(errors).filter((k) => k.startsWith('systemPrompt')).length;
|
||||
const toolsErrors = Object.keys(errors).filter(
|
||||
(k) =>
|
||||
k.startsWith('mcpServers') ||
|
||||
k.startsWith('internalTools') ||
|
||||
k.startsWith('customTools')
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger
|
||||
value="model"
|
||||
icon={<Settings className="h-3.5 w-3.5" />}
|
||||
badge={modelErrors > 0 ? <ErrorBadge count={modelErrors} /> : undefined}
|
||||
>
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="behavior"
|
||||
icon={<Brain className="h-3.5 w-3.5" />}
|
||||
badge={behaviorErrors > 0 ? <ErrorBadge count={behaviorErrors} /> : undefined}
|
||||
>
|
||||
Behavior
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tools"
|
||||
icon={<Wrench className="h-3.5 w-3.5" />}
|
||||
badge={toolsErrors > 0 ? <ErrorBadge count={toolsErrors} /> : undefined}
|
||||
>
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="model" className="flex-1 overflow-y-auto">
|
||||
<ModelTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="behavior" className="flex-1 overflow-y-auto">
|
||||
<BehaviorTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="flex-1 overflow-y-auto">
|
||||
<ToolsTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium bg-destructive text-destructive-foreground rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MODEL TAB - LLM Configuration
|
||||
// ============================================================================
|
||||
|
||||
interface TabProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
function ModelTab({ config, onChange, errors }: TabProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const { data: catalogData, isLoading: catalogLoading } = useLLMCatalog({ mode: 'grouped' });
|
||||
|
||||
const currentProvider = config.llm?.provider || '';
|
||||
const supportsBaseURL = BASE_URL_PROVIDERS.includes(currentProvider);
|
||||
|
||||
const providerModels = useMemo(() => {
|
||||
if (!catalogData || !('providers' in catalogData) || !currentProvider) return [];
|
||||
const providerData =
|
||||
catalogData.providers[currentProvider as keyof typeof catalogData.providers];
|
||||
if (!providerData?.models) return [];
|
||||
return providerData.models.map((m) => ({
|
||||
id: m.name,
|
||||
displayName: m.displayName || m.name,
|
||||
}));
|
||||
}, [catalogData, currentProvider]);
|
||||
|
||||
const updateLLM = (updates: Partial<NonNullable<AgentConfig['llm']>>) => {
|
||||
onChange({
|
||||
...config,
|
||||
llm: { ...config.llm, ...updates } as AgentConfig['llm'],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-8">
|
||||
{/* Language Model Section */}
|
||||
<Section title="Language Model">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Provider" required error={errors['llm.provider']}>
|
||||
<Select
|
||||
value={currentProvider}
|
||||
onValueChange={(value) => {
|
||||
updateLLM({
|
||||
provider: value as never,
|
||||
model: '', // Reset model when switching providers
|
||||
...(value &&
|
||||
!BASE_URL_PROVIDERS.includes(value) && {
|
||||
baseURL: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() +
|
||||
p.slice(1).replace(/-/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Model" required error={errors['llm.model']}>
|
||||
{catalogLoading ? (
|
||||
<div className="flex items-center h-9 px-3 text-sm text-muted-foreground border border-input rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : providerModels.length > 0 ? (
|
||||
<Select
|
||||
value={config.llm?.model || ''}
|
||||
onValueChange={(value) => updateLLM({ model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select model..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config.llm?.model || ''}
|
||||
onChange={(e) => updateLLM({ model: e.target.value })}
|
||||
placeholder={
|
||||
currentProvider
|
||||
? 'Enter model name'
|
||||
: 'Select provider first'
|
||||
}
|
||||
aria-invalid={!!errors['llm.model']}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="API Key" hint="Use $ENV_VAR for environment variables">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={config.llm?.apiKey ?? ''}
|
||||
onChange={(e) => updateLLM({ apiKey: e.target.value })}
|
||||
placeholder="$ANTHROPIC_API_KEY"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Base URL - Only for OpenAI-compatible providers */}
|
||||
{supportsBaseURL && (
|
||||
<Field
|
||||
label="Base URL"
|
||||
required
|
||||
hint="Custom API endpoint for this provider"
|
||||
error={errors['llm.baseURL']}
|
||||
>
|
||||
<Input
|
||||
value={config.llm?.baseURL ?? ''}
|
||||
onChange={(e) =>
|
||||
updateLLM({ baseURL: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium">Advanced Settings</span>
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="ml-6 space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<Field
|
||||
label="Max Output Tokens"
|
||||
hint="Maximum tokens for model responses"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.llm?.maxOutputTokens ?? ''}
|
||||
onChange={(e) =>
|
||||
updateLLM({
|
||||
maxOutputTokens: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="4096"
|
||||
min="1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Greeting Section */}
|
||||
<Section title="Greeting">
|
||||
<Field hint="Initial message shown to users">
|
||||
<Input
|
||||
value={config.greeting || ''}
|
||||
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
|
||||
placeholder="Hello! How can I help you today?"
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BEHAVIOR TAB - System Prompt
|
||||
// ============================================================================
|
||||
|
||||
function BehaviorTab({ config, onChange, errors }: TabProps) {
|
||||
const getPromptContent = (): string => {
|
||||
if (!config.systemPrompt) return '';
|
||||
if (typeof config.systemPrompt === 'string') return config.systemPrompt;
|
||||
const primary = config.systemPrompt.contributors?.find((c) => c.type === 'static');
|
||||
return primary && 'content' in primary ? primary.content : '';
|
||||
};
|
||||
|
||||
const updatePromptContent = (content: string) => {
|
||||
if (!config.systemPrompt || typeof config.systemPrompt === 'string') {
|
||||
onChange({
|
||||
...config,
|
||||
systemPrompt: {
|
||||
contributors: [
|
||||
{ id: 'primary', type: 'static', priority: 0, enabled: true, content },
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const contributors = [...(config.systemPrompt.contributors || [])];
|
||||
const primaryIdx = contributors.findIndex((c) => c.id === 'primary');
|
||||
if (primaryIdx >= 0) {
|
||||
contributors[primaryIdx] = {
|
||||
...contributors[primaryIdx],
|
||||
content,
|
||||
} as ContributorConfig;
|
||||
} else {
|
||||
contributors.unshift({
|
||||
id: 'primary',
|
||||
type: 'static',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content,
|
||||
});
|
||||
}
|
||||
onChange({ ...config, systemPrompt: { contributors } });
|
||||
}
|
||||
};
|
||||
|
||||
const hasMultipleContributors =
|
||||
config.systemPrompt &&
|
||||
typeof config.systemPrompt === 'object' &&
|
||||
config.systemPrompt.contributors &&
|
||||
config.systemPrompt.contributors.length > 1;
|
||||
|
||||
return (
|
||||
<div className="p-5 h-full flex flex-col">
|
||||
<Section title="System Prompt" className="flex-1 flex flex-col">
|
||||
<Field error={errors.systemPrompt} className="flex-1 flex flex-col">
|
||||
<Textarea
|
||||
value={getPromptContent()}
|
||||
onChange={(e) => updatePromptContent(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
className="font-mono text-sm resize-none flex-1 min-h-[400px]"
|
||||
/>
|
||||
</Field>
|
||||
{hasMultipleContributors && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5 mt-3">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
This agent has multiple prompt contributors. Edit in YAML for full control.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOOLS TAB - Internal Tools, Custom Tools, MCP Servers
|
||||
// ============================================================================
|
||||
|
||||
function ToolsTab({ config, onChange, errors }: TabProps) {
|
||||
const { data: discovery, isLoading: discoveryLoading } = useDiscovery();
|
||||
const servers = Object.entries(config.mcpServers || {});
|
||||
|
||||
const enabledInternalTools = (config.internalTools || []) as string[];
|
||||
const toggleInternalTool = (toolName: string) => {
|
||||
const next = enabledInternalTools.includes(toolName)
|
||||
? enabledInternalTools.filter((t) => t !== toolName)
|
||||
: [...enabledInternalTools, toolName];
|
||||
onChange({ ...config, internalTools: next as typeof config.internalTools });
|
||||
};
|
||||
|
||||
const enabledCustomTools = (config.customTools || []).map((t) => t.type);
|
||||
const toggleCustomTool = (toolType: string) => {
|
||||
const current = config.customTools || [];
|
||||
const isEnabled = current.some((t) => t.type === toolType);
|
||||
const next = isEnabled
|
||||
? current.filter((t) => t.type !== toolType)
|
||||
: [...current, { type: toolType }];
|
||||
onChange({ ...config, customTools: next });
|
||||
};
|
||||
|
||||
const toolPolicies = config.toolConfirmation?.toolPolicies || {
|
||||
alwaysAllow: [],
|
||||
alwaysDeny: [],
|
||||
};
|
||||
const alwaysAllowList = toolPolicies.alwaysAllow || [];
|
||||
|
||||
const isToolAutoApproved = (qualifiedName: string) => alwaysAllowList.includes(qualifiedName);
|
||||
|
||||
const toggleToolAutoApprove = (qualifiedName: string) => {
|
||||
const newAlwaysAllow = isToolAutoApproved(qualifiedName)
|
||||
? alwaysAllowList.filter((t) => t !== qualifiedName)
|
||||
: [...alwaysAllowList, qualifiedName];
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
toolConfirmation: {
|
||||
...config.toolConfirmation,
|
||||
toolPolicies: {
|
||||
...toolPolicies,
|
||||
alwaysAllow: newAlwaysAllow,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
const newName = `server-${servers.length + 1}`;
|
||||
onChange({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...config.mcpServers,
|
||||
[newName]: { type: 'stdio', command: '', connectionMode: 'lenient' },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeServer = (name: string) => {
|
||||
const newServers = { ...config.mcpServers };
|
||||
delete newServers[name];
|
||||
onChange({ ...config, mcpServers: newServers });
|
||||
};
|
||||
|
||||
const updateServer = (
|
||||
name: string,
|
||||
updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>
|
||||
) => {
|
||||
const server = config.mcpServers?.[name];
|
||||
if (!server) return;
|
||||
onChange({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...config.mcpServers,
|
||||
[name]: { ...server, ...updates } as NonNullable<AgentConfig['mcpServers']>[string],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const internalToolsCount = discovery?.internalTools?.length || 0;
|
||||
const customToolsCount = discovery?.customTools?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-8">
|
||||
{/* Internal Tools */}
|
||||
<Section title="Internal Tools" description="Built-in capabilities">
|
||||
{discoveryLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading tools...
|
||||
</div>
|
||||
) : internalToolsCount > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{discovery!.internalTools.map((tool) => {
|
||||
const isEnabled = enabledInternalTools.includes(tool.name);
|
||||
const qualifiedName = `internal--${tool.name}`;
|
||||
const isAutoApproved = isToolAutoApproved(qualifiedName);
|
||||
|
||||
return (
|
||||
<ToolRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
description={tool.description}
|
||||
isEnabled={isEnabled}
|
||||
isAutoApproved={isAutoApproved}
|
||||
onToggleEnabled={() => toggleInternalTool(tool.name)}
|
||||
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground/60 pt-3">
|
||||
{enabledInternalTools.length} of {internalToolsCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No internal tools available
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Custom Tools */}
|
||||
{customToolsCount > 0 && (
|
||||
<Section title="Custom Tools" description="Additional providers">
|
||||
<div className="space-y-1">
|
||||
{discovery!.customTools.map((tool) => {
|
||||
const isEnabled = enabledCustomTools.includes(tool.type);
|
||||
const qualifiedName = `custom--${tool.type}`;
|
||||
const isAutoApproved = isToolAutoApproved(qualifiedName);
|
||||
const displayName = tool.metadata?.displayName || tool.type;
|
||||
const description = tool.metadata?.description;
|
||||
|
||||
return (
|
||||
<ToolRow
|
||||
key={tool.type}
|
||||
name={displayName}
|
||||
description={description}
|
||||
isEnabled={isEnabled}
|
||||
isAutoApproved={isAutoApproved}
|
||||
onToggleEnabled={() => toggleCustomTool(tool.type)}
|
||||
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground/60 pt-3">
|
||||
{enabledCustomTools.length} of {customToolsCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* MCP Servers */}
|
||||
<Section title="MCP Servers" description="External tools via Model Context Protocol">
|
||||
{servers.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Server className="h-8 w-8 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">No servers configured</p>
|
||||
<Button onClick={addServer} variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers.map(([name, server]) => (
|
||||
<ServerCard
|
||||
key={name}
|
||||
name={name}
|
||||
server={server}
|
||||
onUpdate={(updates) => updateServer(name, updates)}
|
||||
onRemove={() => removeServer(name)}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({
|
||||
name,
|
||||
description,
|
||||
isEnabled,
|
||||
isAutoApproved,
|
||||
onToggleEnabled,
|
||||
onToggleAutoApprove,
|
||||
}: {
|
||||
name: string;
|
||||
description?: string;
|
||||
isEnabled: boolean;
|
||||
isAutoApproved: boolean;
|
||||
onToggleEnabled: () => void;
|
||||
onToggleAutoApprove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
isEnabled ? 'bg-muted/40' : 'hover:bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={onToggleEnabled}
|
||||
className="h-4 w-4 rounded cursor-pointer shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn('text-sm font-medium', !isEnabled && 'text-muted-foreground')}>
|
||||
{name}
|
||||
</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer shrink-0 px-2 py-1 rounded hover:bg-muted/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAutoApproved}
|
||||
onChange={onToggleAutoApprove}
|
||||
className="h-3 w-3 rounded"
|
||||
/>
|
||||
<span>Auto-approve</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerCard({
|
||||
name,
|
||||
server,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
server: NonNullable<AgentConfig['mcpServers']>[string];
|
||||
onUpdate: (updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>) => void;
|
||||
onRemove: () => void;
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const isStdio = server.type === 'stdio';
|
||||
|
||||
return (
|
||||
<div className="group p-4 rounded-lg bg-muted/30 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-foreground">{name}</span>
|
||||
<Select
|
||||
value={server.type}
|
||||
onValueChange={(type: 'stdio' | 'sse' | 'http') => {
|
||||
if (type === 'stdio') {
|
||||
onUpdate({ type: 'stdio', command: '' } as never);
|
||||
} else {
|
||||
onUpdate({ type, url: '' } as never);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MCP_SERVER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isStdio ? (
|
||||
<Input
|
||||
value={'command' in server ? server.command : ''}
|
||||
onChange={(e) => onUpdate({ command: e.target.value } as never)}
|
||||
placeholder="npx -y @modelcontextprotocol/server-filesystem"
|
||||
className="text-sm font-mono"
|
||||
aria-invalid={!!errors[`mcpServers.${name}.command`]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={'url' in server ? server.url : ''}
|
||||
onChange={(e) => onUpdate({ url: e.target.value } as never)}
|
||||
placeholder="https://mcp.example.com/sse"
|
||||
className="text-sm"
|
||||
aria-invalid={!!errors[`mcpServers.${name}.url`]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-xl bg-muted/20 p-5', className)}>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{hint && !error && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1.5">{hint}</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive mt-1.5">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import FormEditorTabs from './FormEditorTabs';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
|
||||
interface FormEditorViewProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormEditorView - Pure form editor wrapper with tabbed interface
|
||||
*
|
||||
* This component wraps FormEditorTabs and provides a clean interface.
|
||||
* It doesn't handle YAML conversion or loading/saving - that's the parent's job.
|
||||
*
|
||||
* Reusable in both edit and create flows.
|
||||
*/
|
||||
export default function FormEditorView({ config, onChange, errors = {} }: FormEditorViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<FormEditorTabs config={config} onChange={onChange} errors={errors} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import AgentConfigEditor from './AgentConfigEditor';
|
||||
import ConfigValidationStatus from './ConfigValidationStatus';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
|
||||
|
||||
interface YAMLEditorViewProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidate?: (markers: editor.IMarker[]) => void;
|
||||
isValidating?: boolean;
|
||||
isValid?: boolean;
|
||||
errors?: ValidationError[];
|
||||
warnings?: ValidationWarning[];
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* YAMLEditorView - Pure YAML editor with validation display
|
||||
*
|
||||
* This component is responsible for rendering the Monaco YAML editor
|
||||
* and the validation status bar. It doesn't handle loading/saving -
|
||||
* that's the parent's job.
|
||||
*
|
||||
* Reusable in both edit and create flows.
|
||||
*/
|
||||
export default function YAMLEditorView({
|
||||
value,
|
||||
onChange,
|
||||
onValidate,
|
||||
isValidating = false,
|
||||
isValid = true,
|
||||
errors = [],
|
||||
warnings = [],
|
||||
hasUnsavedChanges = false,
|
||||
}: YAMLEditorViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<AgentConfigEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Status */}
|
||||
<ConfigValidationStatus
|
||||
isValidating={isValidating}
|
||||
isValid={isValid}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { LLM_PROVIDERS, isReasoningCapableModel, type AgentConfig } from '@dexto/core';
|
||||
|
||||
type LLMConfig = AgentConfig['llm'];
|
||||
|
||||
interface LLMConfigSectionProps {
|
||||
value: LLMConfig;
|
||||
onChange: (value: LLMConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function LLMConfigSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: LLMConfigSectionProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const handleChange = (field: keyof LLMConfig, newValue: string | number | undefined) => {
|
||||
onChange({ ...value, [field]: newValue } as LLMConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="LLM Configuration"
|
||||
defaultOpen={true}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="provider"
|
||||
tooltip="The LLM provider to use (e.g., OpenAI, Anthropic)"
|
||||
>
|
||||
Provider *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="provider"
|
||||
value={value.provider || ''}
|
||||
onChange={(e) => handleChange('provider', e.target.value)}
|
||||
aria-invalid={!!errors['llm.provider']}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors['llm.provider'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.provider']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="model"
|
||||
tooltip="The specific model identifier (e.g., gpt-5, claude-sonnet-4-5-20250929)"
|
||||
>
|
||||
Model *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="model"
|
||||
value={value.model || ''}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
placeholder="e.g., gpt-5, claude-sonnet-4-5-20250929"
|
||||
aria-invalid={!!errors['llm.model']}
|
||||
/>
|
||||
{errors['llm.model'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.model']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="apiKey"
|
||||
tooltip="Use $ENV_VAR for environment variables or enter the API key directly"
|
||||
>
|
||||
API Key *
|
||||
</LabelWithTooltip>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={value.apiKey ?? ''}
|
||||
onChange={(e) => handleChange('apiKey', e.target.value)}
|
||||
placeholder="$OPENAI_API_KEY or direct value"
|
||||
aria-invalid={!!errors['llm.apiKey']}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded transition-colors"
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors['llm.apiKey'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.apiKey']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max Iterations */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxIterations"
|
||||
tooltip="Maximum number of agent reasoning iterations per turn"
|
||||
>
|
||||
Max Iterations
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxIterations"
|
||||
type="number"
|
||||
value={value.maxIterations !== undefined ? value.maxIterations : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '') {
|
||||
handleChange('maxIterations', undefined);
|
||||
} else {
|
||||
const num = parseInt(val, 10);
|
||||
if (!isNaN(num)) {
|
||||
handleChange('maxIterations', num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="1"
|
||||
placeholder="50"
|
||||
aria-invalid={!!errors['llm.maxIterations']}
|
||||
/>
|
||||
{errors['llm.maxIterations'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxIterations']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="baseURL"
|
||||
tooltip="Custom base URL for the LLM provider (optional, for proxies or custom endpoints)"
|
||||
>
|
||||
Base URL
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="baseURL"
|
||||
value={value.baseURL || ''}
|
||||
onChange={(e) => handleChange('baseURL', e.target.value || undefined)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
aria-invalid={!!errors['llm.baseURL']}
|
||||
/>
|
||||
{errors['llm.baseURL'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.baseURL']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="temperature"
|
||||
tooltip="Controls randomness in responses (0.0 = deterministic, 1.0 = creative)"
|
||||
>
|
||||
Temperature
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="temperature"
|
||||
type="number"
|
||||
value={value.temperature !== undefined ? value.temperature : ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'temperature',
|
||||
e.target.value ? parseFloat(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
placeholder="0.0 - 1.0"
|
||||
aria-invalid={!!errors['llm.temperature']}
|
||||
/>
|
||||
{errors['llm.temperature'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.temperature']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max Input/Output Tokens */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxInputTokens"
|
||||
tooltip="Maximum input tokens to send to the model. If not specified, defaults to model's limit from registry, or 128,000 tokens for custom endpoints"
|
||||
>
|
||||
Max Input Tokens
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxInputTokens"
|
||||
type="number"
|
||||
value={value.maxInputTokens || ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'maxInputTokens',
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
min="1"
|
||||
placeholder="Auto (128k fallback)"
|
||||
aria-invalid={!!errors['llm.maxInputTokens']}
|
||||
/>
|
||||
{errors['llm.maxInputTokens'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxInputTokens']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxOutputTokens"
|
||||
tooltip="Maximum output tokens the model can generate. If not specified, uses provider's default (typically 4,096 tokens)"
|
||||
>
|
||||
Max Output Tokens
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxOutputTokens"
|
||||
type="number"
|
||||
value={value.maxOutputTokens || ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'maxOutputTokens',
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
min="1"
|
||||
placeholder="Auto (provider default)"
|
||||
aria-invalid={!!errors['llm.maxOutputTokens']}
|
||||
/>
|
||||
{errors['llm.maxOutputTokens'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxOutputTokens']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider-Specific Options */}
|
||||
|
||||
{/* Reasoning Effort - Only for models that support it (o1, o3, codex, gpt-5.x) */}
|
||||
{value.model && isReasoningCapableModel(value.model) && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="reasoningEffort"
|
||||
tooltip="Controls reasoning depth for OpenAI models (o1, o3, codex, gpt-5.x). Higher = more thorough but slower/costlier. 'medium' is recommended for most tasks."
|
||||
>
|
||||
Reasoning Effort
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="reasoningEffort"
|
||||
value={value.reasoningEffort || ''}
|
||||
onChange={(e) =>
|
||||
handleChange('reasoningEffort', e.target.value || undefined)
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Auto (model default)</option>
|
||||
<option value="none">None - No reasoning</option>
|
||||
<option value="minimal">Minimal - Barely any reasoning</option>
|
||||
<option value="low">Low - Light reasoning</option>
|
||||
<option value="medium">Medium - Balanced (recommended)</option>
|
||||
<option value="high">High - Thorough reasoning</option>
|
||||
<option value="xhigh">Extra High - Maximum quality</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only applies to reasoning models (o1, o3, codex, gpt-5.x)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
import { MCP_SERVER_TYPES, MCP_CONNECTION_MODES, DEFAULT_MCP_CONNECTION_MODE } from '@dexto/core';
|
||||
|
||||
type McpServersConfig = NonNullable<AgentConfig['mcpServers']>;
|
||||
|
||||
interface McpServersSectionProps {
|
||||
value: McpServersConfig;
|
||||
onChange: (value: McpServersConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function McpServersSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: McpServersSectionProps) {
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
// Local state for text fields that need special parsing (args, env, headers)
|
||||
// Key is "serverName:fieldName", value is the raw string being edited
|
||||
const [editingFields, setEditingFields] = useState<Record<string, string>>({});
|
||||
|
||||
const servers = Object.entries(value || {});
|
||||
|
||||
const toggleServer = (name: string) => {
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
const newName = `server-${Object.keys(value || {}).length + 1}`;
|
||||
onChange({
|
||||
...value,
|
||||
[newName]: {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
connectionMode: 'strict',
|
||||
},
|
||||
});
|
||||
setExpandedServers((prev) => new Set(prev).add(newName));
|
||||
};
|
||||
|
||||
const removeServer = (name: string) => {
|
||||
const newValue = { ...value };
|
||||
delete newValue[name];
|
||||
onChange(newValue);
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateServer = (
|
||||
oldName: string,
|
||||
updates: Partial<Record<string, unknown> & { name?: string }>
|
||||
) => {
|
||||
const server = value[oldName];
|
||||
|
||||
// Extract name from updates if present (it's not part of the server config, just used for the key)
|
||||
const { name: newName, ...serverUpdates } = updates;
|
||||
const newServer = { ...server, ...serverUpdates } as McpServersConfig[string];
|
||||
|
||||
// If name changed via updates, handle the name change
|
||||
if (newName && typeof newName === 'string' && newName !== oldName) {
|
||||
// Guard against collision: prevent overwriting an existing server
|
||||
if (value[newName]) {
|
||||
// TODO: Surface a user-facing error via onChange/errors map or toast notification
|
||||
return; // No-op to avoid overwriting an existing server
|
||||
}
|
||||
const newValue = { ...value };
|
||||
delete newValue[oldName];
|
||||
newValue[newName] = newServer;
|
||||
onChange(newValue);
|
||||
|
||||
// Update expanded state
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(oldName)) {
|
||||
next.delete(oldName);
|
||||
next.add(newName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
onChange({ ...value, [oldName]: newServer });
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current value for a field (either from editing state or from config)
|
||||
const getFieldValue = (serverName: string, fieldName: string, fallback: string): string => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
return editingFields[key] ?? fallback;
|
||||
};
|
||||
|
||||
// Update local editing state while typing
|
||||
const setFieldValue = (serverName: string, fieldName: string, value: string) => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
setEditingFields((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Clear editing state for a field
|
||||
const clearFieldValue = (serverName: string, fieldName: string) => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
setEditingFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Parse and commit args on blur
|
||||
const commitArgs = (serverName: string, argsString: string) => {
|
||||
clearFieldValue(serverName, 'args');
|
||||
|
||||
if (!argsString.trim()) {
|
||||
updateServer(serverName, { args: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const args = argsString
|
||||
.split(',')
|
||||
.map((arg) => arg.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
updateServer(serverName, { args: args.length > 0 ? args : undefined });
|
||||
};
|
||||
|
||||
// Parse and commit env on blur
|
||||
const commitEnv = (serverName: string, envString: string) => {
|
||||
clearFieldValue(serverName, 'env');
|
||||
|
||||
if (!envString.trim()) {
|
||||
updateServer(serverName, { env: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
envString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateServer(serverName, { env: Object.keys(env).length > 0 ? env : undefined });
|
||||
};
|
||||
|
||||
// Parse and commit headers on blur
|
||||
const commitHeaders = (serverName: string, headersString: string) => {
|
||||
clearFieldValue(serverName, 'headers');
|
||||
|
||||
if (!headersString.trim()) {
|
||||
updateServer(serverName, { headers: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
headersString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
headers[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateServer(serverName, {
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="MCP Servers"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No MCP servers configured</p>
|
||||
) : (
|
||||
servers.map(([name, server]) => {
|
||||
const isExpanded = expandedServers.has(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Server Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
onClick={() => toggleServer(name)}
|
||||
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
{'command' in server && server.command && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
({server.command})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeServer(name)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-3 space-y-3">
|
||||
{/* Server Name */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-name-${name}`}
|
||||
tooltip="Unique identifier for this MCP server"
|
||||
>
|
||||
Server Name
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-name-${name}`}
|
||||
value={name}
|
||||
onChange={(e) =>
|
||||
updateServer(name, { name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., filesystem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Type */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-type-${name}`}
|
||||
tooltip="MCP server connection type"
|
||||
>
|
||||
Connection Type *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`server-type-${name}`}
|
||||
value={server.type || 'stdio'}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value as
|
||||
| 'stdio'
|
||||
| 'sse'
|
||||
| 'http';
|
||||
if (type === 'stdio') {
|
||||
updateServer(name, {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
});
|
||||
} else {
|
||||
updateServer(name, {
|
||||
type,
|
||||
url: '',
|
||||
headers: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MCP_SERVER_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type === 'stdio'
|
||||
? 'Standard I/O (stdio)'
|
||||
: type === 'sse'
|
||||
? 'Server-Sent Events (SSE)'
|
||||
: 'HTTP'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* stdio-specific fields */}
|
||||
{server.type === 'stdio' && (
|
||||
<>
|
||||
{/* Command */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-command-${name}`}
|
||||
tooltip="The command to execute (e.g., npx, node, python)"
|
||||
>
|
||||
Command *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-command-${name}`}
|
||||
value={
|
||||
'command' in server
|
||||
? server.command
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
command: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., npx, node, python"
|
||||
aria-invalid={
|
||||
!!errors[`mcpServers.${name}.command`]
|
||||
}
|
||||
/>
|
||||
{errors[`mcpServers.${name}.command`] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors[`mcpServers.${name}.command`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arguments */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-args-${name}`}
|
||||
tooltip="Command arguments, comma-separated"
|
||||
>
|
||||
Arguments
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-args-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'args',
|
||||
('args' in server && server.args
|
||||
? server.args
|
||||
: []
|
||||
).join(', ')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'args',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitArgs(name, e.target.value)
|
||||
}
|
||||
placeholder="--port, 3000, --host, localhost"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-env-${name}`}
|
||||
tooltip="Environment variables in KEY=value format, one per line"
|
||||
>
|
||||
Environment Variables
|
||||
</LabelWithTooltip>
|
||||
<textarea
|
||||
id={`server-env-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'env',
|
||||
Object.entries(
|
||||
('env' in server && server.env) ||
|
||||
{}
|
||||
)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'env',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitEnv(name, e.target.value)
|
||||
}
|
||||
placeholder={`API_KEY=$MY_API_KEY\nPORT=3000`}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* sse/http-specific fields */}
|
||||
{(server.type === 'sse' || server.type === 'http') && (
|
||||
<>
|
||||
{/* URL */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-url-${name}`}
|
||||
tooltip="The URL endpoint for the MCP server"
|
||||
>
|
||||
URL *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-url-${name}`}
|
||||
value={'url' in server ? server.url : ''}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
url: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://example.com/mcp"
|
||||
aria-invalid={
|
||||
!!errors[`mcpServers.${name}.url`]
|
||||
}
|
||||
/>
|
||||
{errors[`mcpServers.${name}.url`] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors[`mcpServers.${name}.url`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-headers-${name}`}
|
||||
tooltip="HTTP headers in KEY=value format, one per line"
|
||||
>
|
||||
Headers
|
||||
</LabelWithTooltip>
|
||||
<textarea
|
||||
id={`server-headers-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'headers',
|
||||
Object.entries(
|
||||
('headers' in server &&
|
||||
server.headers) ||
|
||||
{}
|
||||
)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'headers',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitHeaders(name, e.target.value)
|
||||
}
|
||||
placeholder={`Authorization=Bearer token\nContent-Type=application/json`}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connection Mode */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-mode-${name}`}
|
||||
tooltip="Strict mode fails on any error; lenient mode continues despite errors"
|
||||
>
|
||||
Connection Mode
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`server-mode-${name}`}
|
||||
value={
|
||||
server.connectionMode ||
|
||||
DEFAULT_MCP_CONNECTION_MODE
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
connectionMode: e.target.value as
|
||||
| 'strict'
|
||||
| 'lenient',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MCP_CONNECTION_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode.charAt(0).toUpperCase() +
|
||||
mode.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Add Server Button */}
|
||||
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
|
||||
{errors.mcpServers && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.mcpServers}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import type { AgentConfig, CacheType, DatabaseType } from '@dexto/core';
|
||||
import { CACHE_TYPES, DATABASE_TYPES } from '@dexto/core';
|
||||
|
||||
type StorageConfig = NonNullable<AgentConfig['storage']>;
|
||||
|
||||
interface StorageSectionProps {
|
||||
value: StorageConfig;
|
||||
onChange: (value: StorageConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function StorageSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: StorageSectionProps) {
|
||||
const updateCache = (updates: Partial<Record<string, unknown>>) => {
|
||||
onChange({
|
||||
...value,
|
||||
cache: { ...value.cache, ...updates } as StorageConfig['cache'],
|
||||
});
|
||||
};
|
||||
|
||||
const updateDatabase = (updates: Partial<Record<string, unknown>>) => {
|
||||
onChange({
|
||||
...value,
|
||||
database: { ...value.database, ...updates } as StorageConfig['database'],
|
||||
});
|
||||
};
|
||||
|
||||
const showCacheUrl = value.cache.type === 'redis';
|
||||
const showDatabaseUrl = value.database.type === 'sqlite' || value.database.type === 'postgres';
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="Storage Configuration"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Cache Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Cache</h4>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="cache-type"
|
||||
tooltip="Storage backend for caching data (in-memory or Redis)"
|
||||
>
|
||||
Cache Type
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="cache-type"
|
||||
value={value.cache.type}
|
||||
onChange={(e) => updateCache({ type: e.target.value as CacheType })}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{CACHE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showCacheUrl && 'url' in value.cache && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="cache-url"
|
||||
tooltip="Redis connection URL (e.g., redis://localhost:6379)"
|
||||
>
|
||||
Redis URL
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="cache-url"
|
||||
value={value.cache.url || ''}
|
||||
onChange={(e) => updateCache({ url: e.target.value || undefined })}
|
||||
placeholder="redis://localhost:6379"
|
||||
aria-invalid={!!errors['storage.cache.url']}
|
||||
/>
|
||||
{errors['storage.cache.url'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['storage.cache.url']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Database</h4>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="database-type"
|
||||
tooltip="Storage backend for persistent data (in-memory, SQLite, or PostgreSQL)"
|
||||
>
|
||||
Database Type
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="database-type"
|
||||
value={value.database.type}
|
||||
onChange={(e) =>
|
||||
updateDatabase({ type: e.target.value as DatabaseType })
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{DATABASE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showDatabaseUrl && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="database-url"
|
||||
tooltip={
|
||||
value.database.type === 'sqlite'
|
||||
? 'File path for SQLite database'
|
||||
: 'PostgreSQL connection string'
|
||||
}
|
||||
>
|
||||
{value.database.type === 'sqlite'
|
||||
? 'SQLite Path'
|
||||
: 'PostgreSQL URL'}
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="database-url"
|
||||
value={
|
||||
('url' in value.database && value.database.url) ||
|
||||
('path' in value.database && value.database.path) ||
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (value.database.type === 'sqlite') {
|
||||
updateDatabase({ path: e.target.value || undefined });
|
||||
} else {
|
||||
updateDatabase({ url: e.target.value || undefined });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
value.database.type === 'sqlite'
|
||||
? './dexto.db'
|
||||
: 'postgresql://user:pass@localhost:5432/dexto'
|
||||
}
|
||||
aria-invalid={
|
||||
!!(
|
||||
errors['storage.database.url'] ||
|
||||
errors['storage.database.path']
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(errors['storage.database.url'] ||
|
||||
errors['storage.database.path']) && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['storage.database.url'] ||
|
||||
errors['storage.database.path']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { PROMPT_GENERATOR_SOURCES } from '@dexto/core';
|
||||
import type { ContributorConfig } from '@dexto/core';
|
||||
|
||||
// Component works with the object form of SystemPromptConfig (not the string form)
|
||||
type SystemPromptConfigObject = {
|
||||
contributors: ContributorConfig[];
|
||||
};
|
||||
|
||||
interface SystemPromptSectionProps {
|
||||
value: SystemPromptConfigObject;
|
||||
onChange: (value: SystemPromptConfigObject) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function SystemPromptSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: SystemPromptSectionProps) {
|
||||
const [expandedContributors, setExpandedContributors] = useState<Set<string>>(new Set());
|
||||
// Local state for file paths (comma-separated editing)
|
||||
const [editingFiles, setEditingFiles] = useState<Record<string, string>>({});
|
||||
|
||||
const contributors = value.contributors || [];
|
||||
|
||||
const toggleContributor = (id: string) => {
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addContributor = () => {
|
||||
const newId = `contributor-${Date.now()}`;
|
||||
const newContributor: ContributorConfig = {
|
||||
id: newId,
|
||||
type: 'static',
|
||||
priority: contributors.length * 10,
|
||||
enabled: true,
|
||||
content: '',
|
||||
};
|
||||
onChange({
|
||||
contributors: [...contributors, newContributor],
|
||||
});
|
||||
setExpandedContributors((prev) => new Set(prev).add(newId));
|
||||
};
|
||||
|
||||
const removeContributor = (id: string) => {
|
||||
onChange({
|
||||
contributors: contributors.filter((c) => c.id !== id),
|
||||
});
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateContributor = (id: string, updates: Partial<ContributorConfig>) => {
|
||||
onChange({
|
||||
contributors: contributors.map((c) => {
|
||||
if (c.id === id) {
|
||||
// If ID is changing, handle the ID change
|
||||
if (updates.id && updates.id !== id) {
|
||||
// Update expanded state
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
next.add(updates.id!);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// If type is changing, create a new contributor with the new type
|
||||
if (updates.type && updates.type !== c.type) {
|
||||
const baseFields = {
|
||||
id: updates.id || c.id,
|
||||
priority:
|
||||
updates.priority !== undefined ? updates.priority : c.priority,
|
||||
enabled: updates.enabled !== undefined ? updates.enabled : c.enabled,
|
||||
};
|
||||
|
||||
if (updates.type === 'static') {
|
||||
return {
|
||||
...baseFields,
|
||||
type: 'static',
|
||||
content: '',
|
||||
} as ContributorConfig;
|
||||
} else if (updates.type === 'dynamic') {
|
||||
return {
|
||||
...baseFields,
|
||||
type: 'dynamic',
|
||||
source: 'date',
|
||||
} as ContributorConfig;
|
||||
} else if (updates.type === 'file') {
|
||||
return { ...baseFields, type: 'file', files: [] } as ContributorConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...c, ...updates } as ContributorConfig;
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// Get the current value for file paths (either from editing state or from config)
|
||||
const getFilesValue = (id: string, files: string[]): string => {
|
||||
return editingFiles[id] ?? files.join(', ');
|
||||
};
|
||||
|
||||
// Update local editing state while typing
|
||||
const setFilesValue = (id: string, value: string) => {
|
||||
setEditingFiles((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
// Parse and commit files on blur
|
||||
const commitFiles = (id: string, filesString: string) => {
|
||||
setEditingFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
|
||||
const files = filesString
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
updateContributor(id, { files: files.length > 0 ? files : [] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="System Prompt"
|
||||
defaultOpen={true}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define how the agent should behave using multiple contributors with different
|
||||
priorities.
|
||||
</p>
|
||||
|
||||
{contributors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No contributors configured</p>
|
||||
) : (
|
||||
contributors.map((contributor) => {
|
||||
const isExpanded = expandedContributors.has(contributor.id);
|
||||
return (
|
||||
<div
|
||||
key={contributor.id}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Contributor Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
onClick={() => toggleContributor(contributor.id)}
|
||||
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
{contributor.id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({contributor.type}, priority: {contributor.priority})
|
||||
</span>
|
||||
{contributor.enabled === false && (
|
||||
<span className="text-xs text-destructive">
|
||||
(disabled)
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeContributor(contributor.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contributor Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-3 space-y-3">
|
||||
{/* Common Fields */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-id-${contributor.id}`}
|
||||
tooltip="Unique identifier for this contributor"
|
||||
>
|
||||
ID *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-id-${contributor.id}`}
|
||||
value={contributor.id}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
id: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., primary, date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-type-${contributor.id}`}
|
||||
tooltip="Type of contributor: static (fixed text), dynamic (runtime generated), or file (from files)"
|
||||
>
|
||||
Type *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-type-${contributor.id}`}
|
||||
value={contributor.type}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
type: e.target.value as
|
||||
| 'static'
|
||||
| 'dynamic'
|
||||
| 'file',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="static">Static</option>
|
||||
<option value="dynamic">Dynamic</option>
|
||||
<option value="file">File</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-priority-${contributor.id}`}
|
||||
tooltip="Execution priority (lower numbers run first)"
|
||||
>
|
||||
Priority *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-priority-${contributor.id}`}
|
||||
type="number"
|
||||
value={contributor.priority}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number.parseInt(val, 10);
|
||||
updateContributor(contributor.id, {
|
||||
priority: Number.isNaN(num) ? 0 : num,
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contributor.enabled !== false}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Type-specific Fields */}
|
||||
{contributor.type === 'static' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-content-${contributor.id}`}
|
||||
tooltip="Static content for the system prompt"
|
||||
>
|
||||
Content *
|
||||
</LabelWithTooltip>
|
||||
<Textarea
|
||||
id={`contributor-content-${contributor.id}`}
|
||||
value={contributor.content}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contributor.type === 'dynamic' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-source-${contributor.id}`}
|
||||
tooltip="Source for dynamic content generation"
|
||||
>
|
||||
Source *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-source-${contributor.id}`}
|
||||
value={contributor.source}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
source: e.target.value as Extract<
|
||||
ContributorConfig,
|
||||
{ type: 'dynamic' }
|
||||
>['source'],
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{PROMPT_GENERATOR_SOURCES.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contributor.type === 'file' && (
|
||||
<>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-files-${contributor.id}`}
|
||||
tooltip="File paths to include, comma-separated"
|
||||
>
|
||||
Files *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-files-${contributor.id}`}
|
||||
value={getFilesValue(
|
||||
contributor.id,
|
||||
contributor.files
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFilesValue(
|
||||
contributor.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitFiles(
|
||||
contributor.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="./commands/context.md, ./commands/rules.txt"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Options */}
|
||||
<details className="border border-border rounded-md p-2">
|
||||
<summary className="text-sm font-medium cursor-pointer">
|
||||
File Options
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
contributor.options
|
||||
?.includeFilenames !== false
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
includeFilenames:
|
||||
e.target
|
||||
.checked,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Include filenames as headers
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
contributor.options
|
||||
?.includeMetadata === true
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
includeMetadata:
|
||||
e.target
|
||||
.checked,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>Include file metadata</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-separator-${contributor.id}`}
|
||||
tooltip="Separator between multiple files"
|
||||
>
|
||||
Separator
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-separator-${contributor.id}`}
|
||||
value={
|
||||
contributor.options
|
||||
?.separator ?? '\n\n---\n\n'
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
separator:
|
||||
e.target.value,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
placeholder="\n\n---\n\n"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-error-handling-${contributor.id}`}
|
||||
tooltip="How to handle missing or unreadable files"
|
||||
>
|
||||
Error Handling
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-error-handling-${contributor.id}`}
|
||||
value={
|
||||
contributor.options
|
||||
?.errorHandling || 'skip'
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
errorHandling: e
|
||||
.target
|
||||
.value as
|
||||
| 'skip'
|
||||
| 'error',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="skip">
|
||||
Skip missing files
|
||||
</option>
|
||||
<option value="error">
|
||||
Error on missing files
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-max-file-size-${contributor.id}`}
|
||||
tooltip="Maximum file size in bytes"
|
||||
>
|
||||
Max File Size (bytes)
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-max-file-size-${contributor.id}`}
|
||||
type="number"
|
||||
value={
|
||||
contributor.options
|
||||
?.maxFileSize || 100000
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number.parseInt(
|
||||
val,
|
||||
10
|
||||
);
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
maxFileSize:
|
||||
Number.isNaN(
|
||||
num
|
||||
)
|
||||
? undefined
|
||||
: num,
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="100000"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Add Contributor Button */}
|
||||
<Button onClick={addContributor} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Contributor
|
||||
</Button>
|
||||
|
||||
{errors.systemPrompt && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.systemPrompt}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
import {
|
||||
TOOL_CONFIRMATION_MODES,
|
||||
ALLOWED_TOOLS_STORAGE_TYPES,
|
||||
DEFAULT_TOOL_CONFIRMATION_MODE,
|
||||
DEFAULT_ALLOWED_TOOLS_STORAGE,
|
||||
} from '@dexto/core';
|
||||
|
||||
type ToolConfirmationConfig = NonNullable<AgentConfig['toolConfirmation']>;
|
||||
|
||||
interface ToolConfirmationSectionProps {
|
||||
value: ToolConfirmationConfig;
|
||||
onChange: (value: ToolConfirmationConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function ToolConfirmationSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: ToolConfirmationSectionProps) {
|
||||
const handleChange = (updates: Partial<ToolConfirmationConfig>) => {
|
||||
onChange({ ...value, ...updates });
|
||||
};
|
||||
|
||||
const updateAllowedToolsStorage = (type: 'memory' | 'storage') => {
|
||||
onChange({
|
||||
...value,
|
||||
allowedToolsStorage: type,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="Tool Confirmation"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Confirmation Mode */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="confirmation-mode"
|
||||
tooltip="How the agent handles tool execution requests"
|
||||
>
|
||||
Confirmation Mode
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="confirmation-mode"
|
||||
value={value.mode || DEFAULT_TOOL_CONFIRMATION_MODE}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
mode: e.target.value as 'auto-approve' | 'manual' | 'auto-deny',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{TOOL_CONFIRMATION_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode === 'auto-approve'
|
||||
? 'Auto-approve'
|
||||
: mode === 'manual'
|
||||
? 'Manual'
|
||||
: 'Auto-deny'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{value.mode === 'manual'
|
||||
? 'Require explicit approval before executing tools'
|
||||
: value.mode === 'auto-deny'
|
||||
? 'Automatically deny all tool executions'
|
||||
: 'Automatically approve tool executions'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
{value.mode === 'manual' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="confirmation-timeout"
|
||||
tooltip="How long to wait for approval before timing out"
|
||||
>
|
||||
Timeout (seconds)
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="confirmation-timeout"
|
||||
type="number"
|
||||
value={value.timeout || ''}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
timeout: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
placeholder="e.g., 60"
|
||||
aria-invalid={!!errors['toolConfirmation.timeout']}
|
||||
/>
|
||||
{errors['toolConfirmation.timeout'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['toolConfirmation.timeout']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allowed Tools Storage */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="allowed-tools-storage"
|
||||
tooltip="Where to store the list of pre-approved tools (memory or persistent storage)"
|
||||
>
|
||||
Allowed Tools Storage
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="allowed-tools-storage"
|
||||
value={value.allowedToolsStorage || DEFAULT_ALLOWED_TOOLS_STORAGE}
|
||||
onChange={(e) =>
|
||||
updateAllowedToolsStorage(e.target.value as 'memory' | 'storage')
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{ALLOWED_TOOLS_STORAGE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
643
dexto/packages/webui/components/AgentSelector/AgentSelector.tsx
Normal file
643
dexto/packages/webui/components/AgentSelector/AgentSelector.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useAgents,
|
||||
useAgentPath,
|
||||
useSwitchAgent,
|
||||
useInstallAgent,
|
||||
useUninstallAgent,
|
||||
} from '../hooks/useAgents';
|
||||
import { useRecentAgentsStore } from '@/lib/stores/recentAgentsStore';
|
||||
import { useSessionStore } from '@/lib/stores/sessionStore';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
DownloadCloud,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
BadgeCheck,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import CreateAgentModal from './CreateAgentModal';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
|
||||
type AgentItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
};
|
||||
|
||||
type AgentsResponse = {
|
||||
installed: AgentItem[];
|
||||
available: AgentItem[];
|
||||
current: { id: string | null; name: string | null };
|
||||
};
|
||||
|
||||
type AgentSelectorProps = {
|
||||
mode?: 'default' | 'badge' | 'title';
|
||||
};
|
||||
|
||||
export default function AgentSelector({ mode = 'default' }: AgentSelectorProps) {
|
||||
const navigate = useNavigate();
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const analytics = useAnalytics();
|
||||
const analyticsRef = useRef(analytics);
|
||||
|
||||
const recentAgents = useRecentAgentsStore((state) => state.recentAgents);
|
||||
const addToRecentAgents = useRecentAgentsStore((state) => state.addRecentAgent);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
// Keep analytics ref up to date to avoid stale closure issues
|
||||
useEffect(() => {
|
||||
analyticsRef.current = analytics;
|
||||
}, [analytics]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Invalidate all agent-specific queries when switching agents
|
||||
// This replaces the DOM event pattern (dexto:agentSwitched)
|
||||
const invalidateAgentSpecificQueries = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['sessions', 'history'] }); // All session histories
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'tools'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['greeting'] }); // Hierarchical invalidation
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config }); // Agent config (CustomizePanel)
|
||||
}, [queryClient]);
|
||||
|
||||
// Check if an agent path is from the global ~/.dexto directory
|
||||
// Global pattern: /Users/<user>/.dexto/agents or /home/<user>/.dexto/agents
|
||||
// Also handles Windows: C:\Users\<user>\.dexto\agents
|
||||
const isGlobalAgent = useCallback((path: string): boolean => {
|
||||
// Match paths where .dexto appears within first 4 segments (home directory level)
|
||||
// POSIX: /Users/username/.dexto/agents/... (index 2)
|
||||
// Windows: C:/Users/username/.dexto/agents/... (index 3, drive letter adds extra segment)
|
||||
// Project: /Users/username/Projects/my-project/.dexto/agents/... (5+ segments)
|
||||
// Normalize Windows backslashes to forward slashes for consistent parsing
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const dextoIndex = segments.findIndex((s) => s === '.dexto');
|
||||
return dextoIndex >= 0 && dextoIndex <= 3;
|
||||
}, []);
|
||||
|
||||
// Fetch agents list and path using typed hooks
|
||||
const { data: agentsData, isLoading: agentsLoading, refetch: refetchAgents } = useAgents();
|
||||
const { data: currentAgentPathData } = useAgentPath();
|
||||
|
||||
const installed = useMemo(() => agentsData?.installed || [], [agentsData?.installed]);
|
||||
const available = useMemo(() => agentsData?.available || [], [agentsData?.available]);
|
||||
const currentId = agentsData?.current.id || null;
|
||||
const currentAgentPath = currentAgentPathData ?? null;
|
||||
|
||||
// Agent mutations using typed hooks
|
||||
const switchAgentMutation = useSwitchAgent();
|
||||
const installAgentMutation = useInstallAgent();
|
||||
const deleteAgentMutation = useUninstallAgent();
|
||||
|
||||
// Sync current agent path to recent agents when it loads
|
||||
useEffect(() => {
|
||||
if (currentAgentPath?.path && currentAgentPath?.name) {
|
||||
addToRecentAgents({
|
||||
id: currentAgentPath.name,
|
||||
name: currentAgentPath.name,
|
||||
path: currentAgentPath.path,
|
||||
});
|
||||
}
|
||||
}, [currentAgentPath, addToRecentAgents]);
|
||||
|
||||
const loading = agentsLoading;
|
||||
|
||||
const handleSwitch = useCallback(
|
||||
async (agentId: string) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
// Check if the agent exists in the installed list
|
||||
const agent = installed.find((agent) => agent.id === agentId);
|
||||
if (!agent) {
|
||||
console.error(`Agent not found in installed list: ${agentId}`);
|
||||
throw new Error(
|
||||
`Agent '${agentId}' not found. Please refresh the agents list.`
|
||||
);
|
||||
}
|
||||
|
||||
// Capture current agent ID before switch
|
||||
const fromAgentId = currentId;
|
||||
|
||||
await switchAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
setOpen(false); // Close dropdown after successful switch
|
||||
|
||||
// Track agent switch using ref to avoid stale closure
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agentId,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Navigate back to home after switching agents
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
|
||||
alert(`Failed to switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
installed,
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSwitchToPath = useCallback(
|
||||
async (agent: { id: string; name: string; path: string }) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
|
||||
// Capture current agent ID before switch
|
||||
const fromAgentId = currentId;
|
||||
|
||||
await switchAgentMutation.mutateAsync({ id: agent.id, path: agent.path });
|
||||
|
||||
setOpen(false); // Close dropdown after successful switch
|
||||
|
||||
// Add to recent agents
|
||||
addToRecentAgents(agent);
|
||||
|
||||
// Track agent switch using ref to avoid stale closure
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agent.id,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Navigate back to home after switching agents
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
|
||||
alert(`Failed to switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
addToRecentAgents,
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (agentId: string) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
|
||||
// Capture current agent ID before operations
|
||||
const fromAgentId = currentId;
|
||||
|
||||
// Step 1: Install the agent
|
||||
await installAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
// Step 2: Refetch agents list to ensure cache has fresh data
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.agents.all });
|
||||
|
||||
// Step 3: Verify agent is now in installed list
|
||||
const freshData = queryClient.getQueryData<AgentsResponse>(queryKeys.agents.all);
|
||||
const agent = freshData?.installed.find((a) => a.id === agentId);
|
||||
if (!agent) {
|
||||
throw new Error(
|
||||
`Agent '${agentId}' not found after installation. Please refresh.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Switch to the newly installed agent
|
||||
await switchAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Step 5: Track the switch analytics
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agentId,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Step 6: Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Step 7: Navigate to home
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Install/switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to install/switch agent';
|
||||
alert(`Failed to install/switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
queryClient,
|
||||
installAgentMutation,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (agent: AgentItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering switch when clicking delete
|
||||
if (!confirm(`Are you sure you want to delete the custom agent "${agent.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSwitching(true);
|
||||
await deleteAgentMutation.mutateAsync({ id: agent.id });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Delete agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete agent';
|
||||
alert(`Failed to delete agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[deleteAgentMutation]
|
||||
);
|
||||
|
||||
const currentLabel = useMemo(() => {
|
||||
if (!currentId) return 'Choose Agent';
|
||||
const match =
|
||||
installed.find((agent) => agent.id === currentId) ||
|
||||
available.find((agent) => agent.id === currentId);
|
||||
return match?.name ?? currentId;
|
||||
}, [available, currentId, installed]);
|
||||
|
||||
const handleAgentCreated = useCallback(
|
||||
async (_agentName: string) => {
|
||||
await refetchAgents();
|
||||
},
|
||||
[refetchAgents]
|
||||
);
|
||||
|
||||
const getButtonClassName = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'badge':
|
||||
// Teal text, transparent bg
|
||||
return `h-9 px-4 text-lg font-medium rounded-lg bg-transparent text-teal-600 hover:bg-muted/50 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300 transition-colors min-w-[120px] max-w-[180px] md:min-w-[160px] md:max-w-[280px] lg:max-w-[400px] xl:max-w-[500px]`;
|
||||
case 'title':
|
||||
return `h-11 px-4 text-lg font-bold rounded-lg bg-gradient-to-r from-teal-500/30 to-teal-500/40 text-teal-600 hover:from-teal-500/50 hover:to-teal-500/60 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/40 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
|
||||
default:
|
||||
return `h-10 px-3 text-sm rounded-lg bg-teal-500/40 text-teal-600 hover:bg-teal-500/50 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/50 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={mode === 'badge' ? 'ghost' : 'default'}
|
||||
size="sm"
|
||||
className={getButtonClassName(mode)}
|
||||
disabled={switching}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
{mode !== 'badge' && (
|
||||
<Sparkles className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate min-w-0',
|
||||
mode === 'badge'
|
||||
? 'flex-1 text-left'
|
||||
: 'flex-1 text-center px-1'
|
||||
)}
|
||||
>
|
||||
{switching
|
||||
? 'Switching...'
|
||||
: mode === 'title'
|
||||
? `Agent: ${currentLabel}`
|
||||
: currentLabel}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1.5 flex-shrink-0 opacity-60" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Select agent</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-80 max-h-96 overflow-y-auto shadow-xl border-border/30"
|
||||
>
|
||||
{loading && (
|
||||
<DropdownMenuItem disabled className="text-center text-muted-foreground">
|
||||
Loading agents...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Create New Agent Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCreateModalOpen(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={switching}
|
||||
className="cursor-pointer py-3 px-3 mx-1 my-1 bg-gradient-to-r from-primary/10 to-primary/5 hover:from-primary/15 hover:to-primary/10 border border-primary/20 hover:border-primary/30 transition-all rounded-md shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Plus className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-primary">New Agent</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Current Agent (if loaded from file and not in installed list) */}
|
||||
{currentAgentPath &&
|
||||
!installed.some((a) => a.id === currentAgentPath.name) && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-teal-600 dark:text-teal-400 uppercase tracking-wider border-b border-border/20">
|
||||
Currently Active
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSwitchToPath({
|
||||
id: currentAgentPath.name,
|
||||
name: currentAgentPath.name,
|
||||
path: currentAgentPath.path,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
switching || currentId === currentAgentPath.name
|
||||
}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{currentAgentPath.name}
|
||||
</span>
|
||||
{currentId === currentAgentPath.name && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
Loaded from file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent Agents */}
|
||||
{recentAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Recent
|
||||
</div>
|
||||
{recentAgents
|
||||
.filter(
|
||||
(ra) =>
|
||||
!installed.some((a) => a.id === ra.id) &&
|
||||
ra.id !== currentAgentPath?.name &&
|
||||
!isGlobalAgent(ra.path) // Filter out global dexto directory agents
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.path}
|
||||
onClick={() => handleSwitchToPath(agent)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 truncate"
|
||||
title={agent.path}
|
||||
>
|
||||
{agent.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Installed Custom Agents */}
|
||||
{installed.filter((a) => a.type === 'custom').length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-primary uppercase tracking-wider flex items-center gap-1 border-b border-border/20">
|
||||
<BadgeCheck className="w-3 h-3" />
|
||||
Custom Agents
|
||||
</div>
|
||||
{installed
|
||||
.filter((a) => a.type === 'custom')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleSwitch(agent.id)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDelete(agent, e)}
|
||||
disabled={switching}
|
||||
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="Delete custom agent"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Installed Builtin Agents */}
|
||||
{installed.filter((a) => a.type === 'builtin').length > 0 && (
|
||||
<>
|
||||
{installed.filter((a) => a.type === 'custom').length > 0 && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Installed
|
||||
</div>
|
||||
{installed
|
||||
.filter((a) => a.type === 'builtin')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleSwitch(agent.id)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Available Builtin Agents */}
|
||||
{available.filter((a) => a.type === 'builtin').length > 0 && (
|
||||
<>
|
||||
{installed.length > 0 && <DropdownMenuSeparator />}
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Available
|
||||
</div>
|
||||
{available
|
||||
.filter((a) => a.type === 'builtin')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleInstall(agent.id)}
|
||||
disabled={switching}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
<DownloadCloud className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!loading && installed.length === 0 && available.length === 0 && (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No agents found
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreateAgentModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onAgentCreated={handleAgentCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCreateAgent, type CreateAgentPayload } from '../hooks/useAgents';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { AlertCircle, Loader2, Eye, EyeOff, Info } from 'lucide-react';
|
||||
import { LLM_PROVIDERS } from '@dexto/core';
|
||||
|
||||
interface CreateAgentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAgentCreated?: (agentName: string) => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
id: string;
|
||||
idManuallyEdited: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
id: '',
|
||||
idManuallyEdited: false,
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey: '',
|
||||
systemPrompt: '',
|
||||
};
|
||||
|
||||
// Convert name to a valid ID (lowercase, hyphens, no special chars)
|
||||
function nameToId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
export default function CreateAgentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentCreated,
|
||||
}: CreateAgentModalProps) {
|
||||
const [form, setForm] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const createAgentMutation = useCreateAgent();
|
||||
const isCreating = createAgentMutation.isPending;
|
||||
|
||||
const updateField = (field: keyof FormData, value: string) => {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [field]: value };
|
||||
|
||||
// Auto-generate ID from name if ID hasn't been manually edited
|
||||
if (field === 'name' && !prev.idManuallyEdited) {
|
||||
next.id = nameToId(value);
|
||||
}
|
||||
|
||||
// Mark ID as manually edited if user types in it
|
||||
if (field === 'id') {
|
||||
next.idManuallyEdited = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.id.trim()) {
|
||||
newErrors.id = 'Required';
|
||||
} else if (!/^[a-z0-9-]+$/.test(form.id)) {
|
||||
newErrors.id = 'Lowercase letters, numbers, and hyphens only';
|
||||
}
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = 'Required';
|
||||
}
|
||||
|
||||
if (!form.description.trim()) {
|
||||
newErrors.description = 'Required';
|
||||
}
|
||||
|
||||
if (!form.provider) {
|
||||
newErrors.provider = 'Required';
|
||||
}
|
||||
|
||||
if (!form.model.trim()) {
|
||||
newErrors.model = 'Required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreateError(null);
|
||||
|
||||
const payload: CreateAgentPayload = {
|
||||
id: form.id.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
config: {
|
||||
llm: {
|
||||
provider: form.provider as CreateAgentPayload['config']['llm']['provider'],
|
||||
model: form.model.trim(),
|
||||
apiKey: form.apiKey.trim() || undefined,
|
||||
},
|
||||
...(form.systemPrompt.trim() && {
|
||||
systemPrompt: {
|
||||
contributors: [
|
||||
{
|
||||
id: 'primary',
|
||||
type: 'static' as const,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content: form.systemPrompt.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
createAgentMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
setForm(initialFormData);
|
||||
setErrors({});
|
||||
onOpenChange(false);
|
||||
if (onAgentCreated && data.id) {
|
||||
onAgentCreated(data.id);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setCreateError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setForm(initialFormData);
|
||||
setErrors({});
|
||||
setCreateError(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border/40">
|
||||
<DialogTitle className="text-base">Create Agent</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Configure your new agent. Advanced options can be set after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error */}
|
||||
{createError && (
|
||||
<div className="mx-5 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Identity */}
|
||||
<Section title="Identity">
|
||||
<Field label="Name" required error={errors.name}>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="My Agent"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="ID"
|
||||
required
|
||||
error={errors.id}
|
||||
hint={!form.idManuallyEdited ? 'Auto-generated from name' : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.id}
|
||||
onChange={(e) => updateField('id', e.target.value)}
|
||||
placeholder="my-agent"
|
||||
aria-invalid={!!errors.id}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description" required error={errors.description}>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="A helpful assistant for..."
|
||||
aria-invalid={!!errors.description}
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Model */}
|
||||
<Section title="Language Model">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Provider" required error={errors.provider}>
|
||||
<Select
|
||||
value={form.provider}
|
||||
onValueChange={(value) => updateField('provider', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() +
|
||||
p.slice(1).replace(/-/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Model" required error={errors.model}>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(e) => updateField('model', e.target.value)}
|
||||
placeholder="claude-sonnet-4-5-20250929"
|
||||
aria-invalid={!!errors.model}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="API Key" hint="Leave empty to use environment variable">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
placeholder="$ANTHROPIC_API_KEY"
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* System Prompt */}
|
||||
<Section title="System Prompt" optional>
|
||||
<Field>
|
||||
<Textarea
|
||||
value={form.systemPrompt}
|
||||
onChange={(e) => updateField('systemPrompt', e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={4}
|
||||
className="font-mono text-sm resize-y"
|
||||
/>
|
||||
</Field>
|
||||
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
You can add MCP servers and other options after creation
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="px-5 py-4 border-t border-border/40 bg-muted/20">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function Section({
|
||||
title,
|
||||
optional,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
optional?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{optional && (
|
||||
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{hint && !error && <p className="text-[11px] text-muted-foreground/70 mt-1">{hint}</p>}
|
||||
{error && <p className="text-[11px] text-destructive mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
dexto/packages/webui/components/ApiKeyModal.tsx
Normal file
101
dexto/packages/webui/components/ApiKeyModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { useSaveApiKey, type LLMProvider } from './hooks/useLLM';
|
||||
|
||||
export type ApiKeyModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: LLMProvider;
|
||||
primaryEnvVar: string;
|
||||
onSaved: (meta: { provider: string; envVar: string }) => void;
|
||||
};
|
||||
|
||||
export function ApiKeyModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
primaryEnvVar,
|
||||
onSaved,
|
||||
}: ApiKeyModalProps) {
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const saveApiKeyMutation = useSaveApiKey();
|
||||
|
||||
const submit = () => {
|
||||
if (!apiKey.trim()) {
|
||||
setError('API key is required');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
saveApiKeyMutation.mutate(
|
||||
{ provider, apiKey },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSaved({ provider: data.provider, envVar: data.envVar });
|
||||
onOpenChange(false);
|
||||
setApiKey('');
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message || 'Failed to save API key');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set {provider} API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
This key will be stored in your .env (env var {primaryEnvVar}). It is not
|
||||
shared with the client.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={`Enter ${provider} API key`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saveApiKeyMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saveApiKeyMutation.isPending}>
|
||||
{saveApiKeyMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
449
dexto/packages/webui/components/ApprovalTimeline.tsx
Normal file
449
dexto/packages/webui/components/ApprovalTimeline.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle, ChevronRight, CheckCircle2, XCircle, Terminal, Wrench } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import type { ApprovalEvent } from './ToolConfirmationHandler';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { ApprovalType } from '@dexto/core';
|
||||
|
||||
interface ApprovalTimelineProps {
|
||||
approval: ApprovalEvent;
|
||||
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
export function ApprovalTimeline({ approval, onApprove, onDeny }: ApprovalTimelineProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
|
||||
const updateFormField = (fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
if (formErrors[fieldName]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
if (approval.type === ApprovalType.ELICITATION) {
|
||||
const { schema } = approval.metadata;
|
||||
const required = (schema.required as string[]) || [];
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const fieldName of required) {
|
||||
const value = formData[fieldName];
|
||||
const isEmptyString = typeof value === 'string' && value.trim() === '';
|
||||
if (value === undefined || value === null || isEmptyString) {
|
||||
errors[fieldName] = 'This field is required';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
onApprove(formData);
|
||||
} else {
|
||||
onApprove(undefined, rememberChoice);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate display info based on approval type
|
||||
const getDisplayInfo = () => {
|
||||
let summary = '';
|
||||
let displayName = '';
|
||||
let source = '';
|
||||
|
||||
if (approval.type === ApprovalType.COMMAND_CONFIRMATION) {
|
||||
displayName = 'bash';
|
||||
summary = 'Command requires approval';
|
||||
source = 'system';
|
||||
} else if (approval.type === ApprovalType.TOOL_CONFIRMATION) {
|
||||
const toolName = approval.metadata.toolName;
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
displayName = toolName.substring(5);
|
||||
source = 'mcp';
|
||||
} else {
|
||||
displayName = toolName;
|
||||
}
|
||||
summary = `Tool requires approval`;
|
||||
} else if (approval.type === ApprovalType.ELICITATION) {
|
||||
displayName = approval.metadata.serverName || 'Agent';
|
||||
summary = 'Information requested';
|
||||
source = 'input';
|
||||
}
|
||||
|
||||
return { summary, displayName, source };
|
||||
};
|
||||
|
||||
const { summary, displayName, source } = getDisplayInfo();
|
||||
|
||||
const renderFormField = (fieldName: string, fieldSchema: JSONSchema7, isRequired: boolean) => {
|
||||
const fieldType = fieldSchema.type || 'string';
|
||||
const fieldValue = formData[fieldName];
|
||||
const hasError = !!formErrors[fieldName];
|
||||
const label = fieldSchema.title || fieldName;
|
||||
|
||||
if (fieldType === 'boolean') {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={fieldName}
|
||||
checked={fieldValue === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFormField(fieldName, checked === true)
|
||||
}
|
||||
/>
|
||||
<label htmlFor={fieldName} className="text-xs font-medium">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-[10px] text-muted-foreground/70 ml-6">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'number' || fieldType === 'integer') {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-xs font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
id={fieldName}
|
||||
type="number"
|
||||
step={fieldType === 'integer' ? '1' : 'any'}
|
||||
value={typeof fieldValue === 'number' ? fieldValue : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const nextValue = raw === '' ? undefined : Number(raw);
|
||||
updateFormField(fieldName, nextValue);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-2 py-1.5 border rounded text-xs bg-background',
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
)}
|
||||
placeholder={isRequired ? 'Required' : 'Optional'}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldSchema.enum && Array.isArray(fieldSchema.enum)) {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-xs font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
id={fieldName}
|
||||
value={
|
||||
fieldValue !== undefined && fieldValue !== null
|
||||
? String(fieldValue)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const selected = e.target.value;
|
||||
if (selected === '') {
|
||||
updateFormField(fieldName, undefined);
|
||||
return;
|
||||
}
|
||||
const matched = (fieldSchema.enum as unknown[])?.find(
|
||||
(option) => String(option) === selected
|
||||
);
|
||||
updateFormField(fieldName, matched ?? selected);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-2 py-1.5 border rounded text-xs bg-background',
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{(fieldSchema.enum as unknown[])?.map((option) => (
|
||||
<option key={String(option)} value={String(option)}>
|
||||
{String(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hasError && (
|
||||
<p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-xs font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
id={fieldName}
|
||||
type="text"
|
||||
value={
|
||||
fieldValue !== undefined &&
|
||||
fieldValue !== null &&
|
||||
typeof fieldValue !== 'object'
|
||||
? String(fieldValue)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => updateFormField(fieldName, e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-2 py-1.5 border rounded text-xs bg-background',
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
)}
|
||||
placeholder={isRequired ? 'Required' : 'Optional'}
|
||||
/>
|
||||
{hasError && <p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 animate-slide-up my-1">
|
||||
{/* Timeline column */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Status indicator with pulse */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="absolute inset-0 h-3.5 w-3.5 rounded-full bg-amber-500/30 animate-ping" />
|
||||
</div>
|
||||
{/* Vertical line */}
|
||||
<div className="w-px flex-1 min-h-[8px] bg-amber-500/30" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
{/* Summary line */}
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-1.5 text-left group"
|
||||
>
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
{summary}
|
||||
</span>
|
||||
|
||||
{source && (
|
||||
<span className="text-[10px] text-muted-foreground/40">[{source}]</span>
|
||||
)}
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 text-muted-foreground/40 transition-transform flex-shrink-0',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="text-[10px] text-amber-600/60 dark:text-amber-400/60">
|
||||
needs approval
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Tool/command name */}
|
||||
<div className="text-[10px] text-muted-foreground/35">{displayName}</div>
|
||||
|
||||
{/* Inline action buttons (when not expanded) */}
|
||||
{!expanded && approval.type !== ApprovalType.ELICITATION && (
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700 text-white h-6 text-[11px] px-2.5"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDeny}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[11px] px-2.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For elicitation, always show expanded since form is required */}
|
||||
{approval.type === ApprovalType.ELICITATION && !expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[10px] text-amber-600 dark:text-amber-400 underline"
|
||||
>
|
||||
Click to provide input...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-3 animate-fade-in">
|
||||
{/* Command confirmation */}
|
||||
{approval.type === ApprovalType.COMMAND_CONFIRMATION && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Terminal className="h-3 w-3" />
|
||||
<span>Command</span>
|
||||
</div>
|
||||
<pre className="bg-muted/30 rounded-md p-2 text-[10px] font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||||
{approval.metadata.command}
|
||||
</pre>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-2 rounded-md text-[10px] text-amber-800 dark:text-amber-200">
|
||||
This command may modify your system.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tool confirmation */}
|
||||
{approval.type === ApprovalType.TOOL_CONFIRMATION && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span>{approval.metadata.toolName}</span>
|
||||
</div>
|
||||
{approval.metadata.description && (
|
||||
<p className="text-xs text-foreground/70">
|
||||
{approval.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-[9px] font-semibold text-muted-foreground/60 uppercase mb-1">
|
||||
Arguments
|
||||
</h4>
|
||||
<div className="bg-muted/30 rounded-md p-1.5 space-y-0.5">
|
||||
{Object.entries(approval.metadata.args || {}).map(
|
||||
([key, value]) => (
|
||||
<div key={key} className="flex gap-1.5 text-[10px]">
|
||||
<span className="text-muted-foreground font-medium shrink-0">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-foreground/70 font-mono break-all">
|
||||
{typeof value === 'string'
|
||||
? value
|
||||
: typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberChoice(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-[10px] text-muted-foreground"
|
||||
>
|
||||
Remember for this session
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Elicitation (form) */}
|
||||
{approval.type === ApprovalType.ELICITATION && (
|
||||
<>
|
||||
<div className="bg-muted/30 p-2 rounded-md">
|
||||
<p className="text-xs font-medium break-words">
|
||||
{approval.metadata.prompt}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const { schema } = approval.metadata;
|
||||
if (
|
||||
!schema?.properties ||
|
||||
typeof schema.properties !== 'object'
|
||||
) {
|
||||
return (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
Invalid form schema
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const required = (schema.required as string[]) || [];
|
||||
const properties = schema.properties;
|
||||
|
||||
return Object.entries(properties).map(
|
||||
([fieldName, fieldSchema]) => {
|
||||
const isRequired = required.includes(fieldName);
|
||||
return renderFormField(
|
||||
fieldName,
|
||||
fieldSchema as JSONSchema7,
|
||||
isRequired
|
||||
);
|
||||
}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons (expanded view) */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
size="sm"
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white h-7 text-xs"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{approval.type === ApprovalType.ELICITATION ? 'Submit' : 'Approve'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDeny}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20 h-7 text-xs"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
{approval.type === ApprovalType.ELICITATION ? 'Decline' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1529
dexto/packages/webui/components/ChatApp.tsx
Normal file
1529
dexto/packages/webui/components/ChatApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
136
dexto/packages/webui/components/ChatInput/AttachButton.tsx
Normal file
136
dexto/packages/webui/components/ChatInput/AttachButton.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Paperclip, File, FileAudio } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AttachButtonProps {
|
||||
onImageAttach: () => void;
|
||||
onPdfAttach: () => void;
|
||||
onAudioAttach: () => void;
|
||||
className?: string;
|
||||
supports?: {
|
||||
image?: boolean;
|
||||
pdf?: boolean;
|
||||
audio?: boolean;
|
||||
};
|
||||
/** Use lg breakpoint instead of md for responsive text */
|
||||
useLargeBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
export function AttachButton({
|
||||
onImageAttach,
|
||||
onPdfAttach,
|
||||
onAudioAttach,
|
||||
className,
|
||||
supports,
|
||||
useLargeBreakpoint = false,
|
||||
}: AttachButtonProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const imageSupported = supports?.image !== false; // default to true if unknown
|
||||
const pdfSupported = supports?.pdf !== false; // default to true if unknown
|
||||
const audioSupported = supports?.audio !== false;
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2 text-sm text-muted-foreground hover:text-foreground rounded-full',
|
||||
useLargeBreakpoint ? 'lg:px-3' : 'md:px-3',
|
||||
className
|
||||
)}
|
||||
aria-label="Attach File"
|
||||
>
|
||||
<Paperclip
|
||||
className={cn('h-3 w-3', useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5')}
|
||||
/>
|
||||
<span className={cn('hidden', useLargeBreakpoint ? 'lg:inline' : 'md:inline')}>
|
||||
Attach
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!imageSupported) return;
|
||||
onImageAttach();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={!imageSupported ? 'opacity-50 cursor-not-allowed' : undefined}
|
||||
aria-disabled={!imageSupported}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Paperclip className="h-4 w-4 mr-2" /> Image
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!imageSupported && (
|
||||
<TooltipContent side="bottom">
|
||||
Unsupported for this model
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!pdfSupported) return;
|
||||
onPdfAttach();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={!pdfSupported ? 'opacity-50 cursor-not-allowed' : undefined}
|
||||
aria-disabled={!pdfSupported}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<File className="h-4 w-4 mr-2" /> PDF
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!pdfSupported && (
|
||||
<TooltipContent side="bottom">
|
||||
Unsupported for this model
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!audioSupported) return;
|
||||
onAudioAttach();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={!audioSupported ? 'opacity-50 cursor-not-allowed' : undefined}
|
||||
aria-disabled={!audioSupported}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<FileAudio className="h-4 w-4 mr-2" /> Audio file
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!audioSupported && (
|
||||
<TooltipContent side="bottom">
|
||||
Unsupported for this model
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
30
dexto/packages/webui/components/ChatInput/ButtonFooter.tsx
Normal file
30
dexto/packages/webui/components/ChatInput/ButtonFooter.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonFooterProps {
|
||||
leftButtons?: React.ReactNode;
|
||||
rightButtons?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ButtonFooter({ leftButtons, rightButtons, className }: ButtonFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Normal flow footer row
|
||||
'flex items-center justify-between',
|
||||
// Fixed footer height with safe area padding for mobile
|
||||
'h-12 px-3 pr-4',
|
||||
// No visual separator; seamless with editor area
|
||||
// Ensure interactions work normally
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left side buttons (Attach, Record, etc.) */}
|
||||
<div className="flex items-center gap-2">{leftButtons}</div>
|
||||
|
||||
{/* Right side buttons (Send) */}
|
||||
<div className="flex items-center gap-2">{rightButtons}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({ children, className }: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
'w-full',
|
||||
// Vertical layout: editor (scrollable) + footer (fixed)
|
||||
// Allow overlays (e.g., slash autocomplete) to escape the editor area
|
||||
'flex flex-col overflow-visible',
|
||||
'max-h-[max(35svh,5rem)]', // commonly used responsive height
|
||||
'border border-border/30',
|
||||
// Opaque background to prevent underlying text/blur artifacts
|
||||
'bg-background',
|
||||
'rounded-3xl',
|
||||
'shadow-lg hover:shadow-xl',
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
dexto/packages/webui/components/ChatInput/RecordButton.tsx
Normal file
66
dexto/packages/webui/components/ChatInput/RecordButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Mic, StopCircle } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RecordButtonProps {
|
||||
isRecording: boolean;
|
||||
onToggleRecording: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/** Use lg breakpoint instead of md for responsive text */
|
||||
useLargeBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
export function RecordButton({
|
||||
isRecording,
|
||||
onToggleRecording,
|
||||
className,
|
||||
disabled,
|
||||
useLargeBreakpoint = false,
|
||||
}: RecordButtonProps) {
|
||||
const btn = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!disabled) onToggleRecording();
|
||||
}}
|
||||
className={cn(
|
||||
'h-8 px-2 text-sm rounded-full',
|
||||
useLargeBreakpoint ? 'lg:px-3' : 'md:px-3',
|
||||
disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
aria-label={isRecording ? 'Stop recording' : 'Record audio'}
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
>
|
||||
{isRecording ? (
|
||||
<StopCircle
|
||||
className={cn(
|
||||
'h-3 w-3 text-red-500',
|
||||
useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className={cn('h-3 w-3', useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5')} />
|
||||
)}
|
||||
<span className={cn('hidden', useLargeBreakpoint ? 'lg:inline' : 'md:inline')}>
|
||||
{isRecording ? 'Stop' : 'Record'}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
return disabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Unsupported for this model</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
btn
|
||||
);
|
||||
}
|
||||
40
dexto/packages/webui/components/ChatInput/StreamToggle.tsx
Normal file
40
dexto/packages/webui/components/ChatInput/StreamToggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../ui/tooltip';
|
||||
|
||||
interface StreamToggleProps {
|
||||
isStreaming: boolean;
|
||||
onStreamingChange: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StreamToggle({ isStreaming, onStreamingChange, className }: StreamToggleProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer ${className || ''}`}>
|
||||
<Zap
|
||||
className={`h-3 w-3 ${isStreaming ? 'text-blue-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={isStreaming}
|
||||
onCheckedChange={onStreamingChange}
|
||||
className="scale-75"
|
||||
aria-label="Toggle streaming"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isStreaming ? 'Streaming enabled' : 'Streaming disabled'}</p>
|
||||
<p className="text-xs opacity-75">
|
||||
{isStreaming
|
||||
? 'Responses will stream in real-time'
|
||||
: 'Responses will arrive all at once'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
5
dexto/packages/webui/components/ChatInput/index.ts
Normal file
5
dexto/packages/webui/components/ChatInput/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ChatInputContainer } from './ChatInputContainer';
|
||||
export { ButtonFooter } from './ButtonFooter';
|
||||
export { StreamToggle } from './StreamToggle';
|
||||
export { AttachButton } from './AttachButton';
|
||||
export { RecordButton } from './RecordButton';
|
||||
395
dexto/packages/webui/components/CodePreview.tsx
Normal file
395
dexto/packages/webui/components/CodePreview.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* CodePreview Component
|
||||
*
|
||||
* Displays code with syntax highlighting, scrollable preview,
|
||||
* and option to expand to full-screen Monaco editor.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, lazy, Suspense } from 'react';
|
||||
import { Copy, Check, Maximize2, X, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
|
||||
// Register common languages
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import rust from 'highlight.js/lib/languages/rust';
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('js', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('ts', typescript);
|
||||
hljs.registerLanguage('tsx', typescript);
|
||||
hljs.registerLanguage('jsx', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('py', python);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('sh', bash);
|
||||
hljs.registerLanguage('shell', bash);
|
||||
hljs.registerLanguage('html', xml);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('yml', yaml);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('md', markdown);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('rust', rust);
|
||||
hljs.registerLanguage('rs', rust);
|
||||
|
||||
// Lazy load Monaco for full editor view
|
||||
const Editor = lazy(() => import('@monaco-editor/react'));
|
||||
|
||||
interface CodePreviewProps {
|
||||
/** Code content to display */
|
||||
content: string;
|
||||
/** File path for language detection and display */
|
||||
filePath?: string;
|
||||
/** Override detected language */
|
||||
language?: string;
|
||||
/** Maximum lines before showing "show more" (default: 10) */
|
||||
maxLines?: number;
|
||||
/** Whether to show line numbers (default: true) */
|
||||
showLineNumbers?: boolean;
|
||||
/** Maximum height in pixels for the preview (default: 200) */
|
||||
maxHeight?: number;
|
||||
/** Optional title/label */
|
||||
title?: string;
|
||||
/** Show icon before title */
|
||||
showIcon?: boolean;
|
||||
/** Show header with title/actions (default: true) */
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
// Map file extensions to hljs/monaco languages
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
mts: 'typescript',
|
||||
cts: 'typescript',
|
||||
json: 'json',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
css: 'css',
|
||||
scss: 'css',
|
||||
less: 'css',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
mdx: 'markdown',
|
||||
sql: 'sql',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
toml: 'yaml',
|
||||
ini: 'yaml',
|
||||
env: 'bash',
|
||||
dockerfile: 'bash',
|
||||
makefile: 'bash',
|
||||
};
|
||||
|
||||
function getLanguageFromPath(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const filename = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
|
||||
// Check special filenames
|
||||
if (filename === 'dockerfile') return 'bash';
|
||||
if (filename === 'makefile') return 'bash';
|
||||
if (filename.startsWith('.env')) return 'bash';
|
||||
|
||||
return EXT_TO_LANG[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
function getShortPath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 2) return path;
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent XSS when using dangerouslySetInnerHTML
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function CodePreview({
|
||||
content,
|
||||
filePath,
|
||||
language: overrideLanguage,
|
||||
maxLines = 10,
|
||||
showLineNumbers = true,
|
||||
maxHeight = 200,
|
||||
title,
|
||||
showIcon = true,
|
||||
showHeader = true,
|
||||
}: CodePreviewProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [showFullScreen, setShowFullScreen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const language = overrideLanguage || (filePath ? getLanguageFromPath(filePath) : 'plaintext');
|
||||
const lines = content.split('\n');
|
||||
const shouldTruncate = lines.length > maxLines && !showAll;
|
||||
const displayContent = shouldTruncate ? lines.slice(0, maxLines).join('\n') : content;
|
||||
|
||||
// Apply HTML escaping before syntax highlighting to prevent XSS
|
||||
let highlightedContent: string;
|
||||
try {
|
||||
if (language !== 'plaintext') {
|
||||
// Escape HTML entities first, then highlight the escaped content
|
||||
const escaped = escapeHtml(displayContent);
|
||||
const result = hljs.highlight(escaped, { language, ignoreIllegals: true });
|
||||
highlightedContent = result.value;
|
||||
} else {
|
||||
// Plaintext - escape HTML entities
|
||||
highlightedContent = escapeHtml(displayContent);
|
||||
}
|
||||
} catch {
|
||||
// Highlight failed - escape HTML entities for safety
|
||||
highlightedContent = escapeHtml(displayContent);
|
||||
}
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [content]);
|
||||
|
||||
// Handle escape key to close full screen modal
|
||||
useEffect(() => {
|
||||
if (!showFullScreen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowFullScreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showFullScreen]);
|
||||
|
||||
const displayTitle = title || (filePath ? getShortPath(filePath) : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{/* Header */}
|
||||
{showHeader && (displayTitle || filePath) && (
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{showIcon && <FileText className="h-3 w-3" />}
|
||||
<span className="font-mono">{displayTitle}</span>
|
||||
<span className="text-[10px]">{lines.length} lines</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy code to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFullScreen(true)}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Open full view"
|
||||
aria-label="Open code in full screen"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code preview */}
|
||||
<div
|
||||
className="bg-zinc-100 dark:bg-zinc-950 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800"
|
||||
style={{ maxHeight: showAll ? undefined : maxHeight }}
|
||||
>
|
||||
<div
|
||||
className={cn('overflow-auto', showAll ? 'max-h-[400px]' : '')}
|
||||
style={{ maxHeight: showAll ? 400 : maxHeight - 2 }}
|
||||
>
|
||||
<pre className="p-2 text-[11px] font-mono leading-relaxed">
|
||||
{showLineNumbers ? (
|
||||
<code>
|
||||
{(showAll ? content : displayContent)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="w-8 pr-2 text-right text-zinc-400 dark:text-zinc-600 select-none flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span
|
||||
className="text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-all flex-1"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
const lineContent = line || ' ';
|
||||
// Always escape first to prevent XSS
|
||||
const escaped = escapeHtml(lineContent);
|
||||
try {
|
||||
if (language !== 'plaintext') {
|
||||
return hljs.highlight(escaped, {
|
||||
language,
|
||||
ignoreIllegals: true,
|
||||
}).value;
|
||||
}
|
||||
} catch {
|
||||
// fallback - already escaped above
|
||||
}
|
||||
return escaped;
|
||||
})(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
className="text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-all"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedContent }}
|
||||
/>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Show more button */}
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full py-1 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Show {lines.length - maxLines} more lines...
|
||||
</button>
|
||||
)}
|
||||
{showAll && lines.length > maxLines && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className="w-full py-1 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full screen modal with Monaco */}
|
||||
{showFullScreen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/80 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Code preview"
|
||||
>
|
||||
<div className="relative w-[90vw] h-[85vh] bg-white dark:bg-zinc-900 rounded-lg shadow-2xl flex flex-col overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
{/* Modal header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-100 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="font-mono">{filePath || 'Code'}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-500">
|
||||
({lines.length} lines)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded transition-colors"
|
||||
aria-label={copied ? 'Code copied' : 'Copy code to clipboard'}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFullScreen(false)}
|
||||
className="p-1 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded transition-colors"
|
||||
aria-label="Close full screen view"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monaco editor */}
|
||||
<div className="flex-1">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full text-zinc-500">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language === 'plaintext' ? undefined : language}
|
||||
value={content}
|
||||
theme={theme === 'dark' ? 'vs-dark' : 'light'}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderLineHighlight: 'all',
|
||||
folding: true,
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click outside to close */}
|
||||
<div
|
||||
className="absolute inset-0 -z-10"
|
||||
onClick={() => setShowFullScreen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
496
dexto/packages/webui/components/ConnectServerModal.tsx
Normal file
496
dexto/packages/webui/components/ConnectServerModal.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type {
|
||||
McpServerConfig,
|
||||
StdioServerConfig,
|
||||
SseServerConfig,
|
||||
HttpServerConfig,
|
||||
} from '@dexto/core';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { KeyValueEditor } from './ui/key-value-editor';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { useAddServer } from './hooks/useServers';
|
||||
|
||||
interface ConnectServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onServerConnected?: () => void;
|
||||
initialName?: string;
|
||||
initialConfig?:
|
||||
| Partial<StdioServerConfig>
|
||||
| Partial<SseServerConfig>
|
||||
| Partial<HttpServerConfig>;
|
||||
lockName?: boolean;
|
||||
}
|
||||
|
||||
export default function ConnectServerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onServerConnected,
|
||||
initialName,
|
||||
initialConfig,
|
||||
lockName,
|
||||
}: ConnectServerModalProps) {
|
||||
const addServerMutation = useAddServer();
|
||||
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'http'>('stdio');
|
||||
const [command, setCommand] = useState('');
|
||||
const [args, setArgs] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [headerPairs, setHeaderPairs] = useState<
|
||||
Array<{ key: string; value: string; id: string }>
|
||||
>([]);
|
||||
const [envPairs, setEnvPairs] = useState<Array<{ key: string; value: string; id: string }>>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [persistToAgent, setPersistToAgent] = useState(false);
|
||||
|
||||
// Helper function to convert header pairs to record
|
||||
const headersToRecord = (
|
||||
pairs: Array<{ key: string; value: string; id: string }>
|
||||
): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
pairs.forEach((pair) => {
|
||||
if (pair.key.trim() && pair.value.trim()) {
|
||||
headers[pair.key.trim()] = pair.value.trim();
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Helper function to convert env pairs to record
|
||||
const envToRecord = (
|
||||
pairs: Array<{ key: string; value: string; id: string }>
|
||||
): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const { key, value } of pairs) {
|
||||
const k = key.trim();
|
||||
const v = value.trim();
|
||||
if (k && v) env[k] = v;
|
||||
}
|
||||
return env;
|
||||
};
|
||||
|
||||
// Helper function to mask sensitive environment values for logging
|
||||
const maskSensitiveEnv = (env: Record<string, string>): Record<string, string> => {
|
||||
const sensitiveKeys = ['api_key', 'secret', 'token', 'password', 'key'];
|
||||
const masked: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const isSensitive = sensitiveKeys.some((sk) => key.toLowerCase().includes(sk));
|
||||
masked[key] = isSensitive ? '***masked***' : value;
|
||||
}
|
||||
return masked;
|
||||
};
|
||||
|
||||
// Helper to mask sensitive headers for logging
|
||||
const maskSensitiveHeaders = (headers: Record<string, string>): Record<string, string> => {
|
||||
const sensitive = [
|
||||
'authorization',
|
||||
'proxy-authorization',
|
||||
'api-key',
|
||||
'x-api-key',
|
||||
'token',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
];
|
||||
const masked: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
const key = k.toLowerCase();
|
||||
const isSensitive = sensitive.some((s) => key === s || key.includes(s));
|
||||
masked[k] = isSensitive ? '***masked***' : v;
|
||||
}
|
||||
return masked;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
setServerName('');
|
||||
setServerType('stdio');
|
||||
setCommand('');
|
||||
setArgs('');
|
||||
setUrl('');
|
||||
setHeaderPairs([]);
|
||||
setEnvPairs([]);
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Apply initialName/initialConfig when they change and modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setServerName(initialName ?? '');
|
||||
const type = initialConfig?.type ?? 'stdio';
|
||||
setServerType(type);
|
||||
if (type === 'stdio') {
|
||||
const std = (initialConfig ?? {}) as Partial<StdioServerConfig>;
|
||||
setCommand(typeof std.command === 'string' ? std.command : '');
|
||||
setArgs(Array.isArray(std.args) ? std.args.join(', ') : '');
|
||||
const envEntries = Object.entries(std.env ?? {});
|
||||
setEnvPairs(
|
||||
envEntries.map(([key, value], idx) => ({
|
||||
key,
|
||||
value: String(value ?? ''),
|
||||
id: `env-${idx}`,
|
||||
}))
|
||||
);
|
||||
// clear URL/header state
|
||||
setUrl('');
|
||||
setHeaderPairs([]);
|
||||
} else {
|
||||
const net = (initialConfig ?? {}) as Partial<SseServerConfig | HttpServerConfig>;
|
||||
setUrl(typeof net.url === 'string' ? net.url : '');
|
||||
const hdrEntries = Object.entries(net.headers ?? {});
|
||||
setHeaderPairs(
|
||||
hdrEntries.map(([key, value], idx) => ({
|
||||
key,
|
||||
value: String(value ?? ''),
|
||||
id: `hdr-${idx}`,
|
||||
}))
|
||||
);
|
||||
// clear stdio state
|
||||
setCommand('');
|
||||
setArgs('');
|
||||
setEnvPairs([]);
|
||||
}
|
||||
}, [isOpen, initialName, initialConfig]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Validate server name
|
||||
if (!serverName.trim()) {
|
||||
setError('Server name is required.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields based on server type
|
||||
if (serverType === 'stdio') {
|
||||
if (!command.trim()) {
|
||||
setError('Command is required for stdio servers.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
const requiredKeys = envPairs.map((p) => p.key.trim()).filter(Boolean);
|
||||
if (requiredKeys.length) {
|
||||
const dupes = requiredKeys.filter((k, i) => requiredKeys.indexOf(k) !== i);
|
||||
if (dupes.length) {
|
||||
setError(
|
||||
`Duplicate environment variables: ${Array.from(new Set(dupes)).join(', ')}`
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const missing = envPairs
|
||||
.filter((p) => p.key.trim() && !p.value.trim())
|
||||
.map((p) => p.key.trim());
|
||||
if (missing.length) {
|
||||
setError(`Please set required environment variables: ${missing.join(', ')}`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!url.trim()) {
|
||||
setError(`URL is required for ${serverType.toUpperCase()} servers.`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(url.trim());
|
||||
} catch (_) {
|
||||
setError(`Invalid URL format for ${serverType.toUpperCase()} server.`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create config after all validation passes
|
||||
let config: McpServerConfig;
|
||||
if (serverType === 'stdio') {
|
||||
config = {
|
||||
type: 'stdio',
|
||||
command: command.trim(),
|
||||
args: args
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
env: envToRecord(envPairs),
|
||||
timeout: 30000,
|
||||
connectionMode: 'lenient',
|
||||
};
|
||||
} else if (serverType === 'sse') {
|
||||
config = {
|
||||
type: 'sse',
|
||||
url: url.trim(),
|
||||
headers: headerPairs.length ? headersToRecord(headerPairs) : {},
|
||||
timeout: 30000,
|
||||
connectionMode: 'lenient',
|
||||
};
|
||||
} else {
|
||||
config = {
|
||||
type: 'http',
|
||||
url: url.trim(),
|
||||
headers: headerPairs.length ? headersToRecord(headerPairs) : {},
|
||||
timeout: 30000,
|
||||
connectionMode: 'lenient',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await addServerMutation.mutateAsync({
|
||||
name: serverName.trim(),
|
||||
config,
|
||||
persistToAgent,
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// Create a safe version for logging with masked sensitive values
|
||||
const safeConfig = { ...config };
|
||||
if (safeConfig.type === 'stdio' && safeConfig.env) {
|
||||
safeConfig.env = maskSensitiveEnv(safeConfig.env);
|
||||
} else if (
|
||||
(safeConfig.type === 'sse' || safeConfig.type === 'http') &&
|
||||
safeConfig.headers
|
||||
) {
|
||||
safeConfig.headers = maskSensitiveHeaders(safeConfig.headers);
|
||||
}
|
||||
console.debug(
|
||||
`[ConnectServerModal.handleSubmit] Connected server with config: ${JSON.stringify(safeConfig)}`
|
||||
);
|
||||
}
|
||||
|
||||
onServerConnected?.();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to connect server';
|
||||
if (err instanceof Error) {
|
||||
message = err.message || message;
|
||||
} else if (typeof err === 'string') {
|
||||
message = err;
|
||||
}
|
||||
addServerMutation.reset();
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect New MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure connection details for a new MCP server (stdio, SSE, or HTTP).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="connectServerForm" onSubmit={handleSubmit} className="grid gap-4 py-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="serverName" className="text-right">
|
||||
Server Name
|
||||
</Label>
|
||||
<Input
|
||||
id="serverName"
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g., My Local Tools"
|
||||
required
|
||||
disabled={isSubmitting || !!lockName}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="serverType" className="text-right">
|
||||
Server Type
|
||||
</Label>
|
||||
<Select
|
||||
value={serverType}
|
||||
onValueChange={(value: 'stdio' | 'sse' | 'http') =>
|
||||
setServerType(value)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="serverType" className="col-span-3">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{serverType === 'stdio' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="command" className="text-right">
|
||||
Command
|
||||
</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g., /path/to/executable or python"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="args" className="text-right">
|
||||
Arguments
|
||||
</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Comma-separated, e.g., -m,script.py,--port,8080"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right mt-2">Environment</Label>
|
||||
<div className="col-span-3">
|
||||
<KeyValueEditor
|
||||
pairs={envPairs}
|
||||
onChange={setEnvPairs}
|
||||
placeholder={{
|
||||
key: 'API_KEY',
|
||||
value: 'your-secret-key',
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
keyLabel="Variable"
|
||||
valueLabel="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : serverType === 'sse' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="url" className="text-right">
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g., http://localhost:8000/events"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right mt-2">Headers</Label>
|
||||
<div className="col-span-3">
|
||||
<KeyValueEditor
|
||||
pairs={headerPairs}
|
||||
onChange={setHeaderPairs}
|
||||
placeholder={{
|
||||
key: 'Authorization',
|
||||
value: 'Bearer your-token',
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
keyLabel="Header"
|
||||
valueLabel="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="url" className="text-right">
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g., http://localhost:8080"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right mt-2">Headers</Label>
|
||||
<div className="col-span-3">
|
||||
<KeyValueEditor
|
||||
pairs={headerPairs}
|
||||
onChange={setHeaderPairs}
|
||||
placeholder={{
|
||||
key: 'Authorization',
|
||||
value: 'Bearer your-token',
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
keyLabel="Header"
|
||||
valueLabel="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Persist to Agent Checkbox */}
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="persistToAgent"
|
||||
checked={persistToAgent}
|
||||
onCheckedChange={(checked) => setPersistToAgent(checked === true)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="persistToAgent"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Save to agent configuration file
|
||||
</Label>
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="connectServerForm" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
149
dexto/packages/webui/components/CreateMemoryModal.tsx
Normal file
149
dexto/packages/webui/components/CreateMemoryModal.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCreateMemory } from './hooks/useMemories';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
|
||||
interface CreateMemoryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateMemoryModal({ open, onClose }: CreateMemoryModalProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
|
||||
const createMemoryMutation = useCreateMemory();
|
||||
|
||||
const handleSuccess = () => {
|
||||
setContent('');
|
||||
setTags('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const payload = {
|
||||
content: content.trim(),
|
||||
...(tags.trim() && {
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
metadata: { source: 'user' as const },
|
||||
};
|
||||
|
||||
createMemoryMutation.mutate(payload, {
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!createMemoryMutation.isPending) {
|
||||
setContent('');
|
||||
setTags('');
|
||||
createMemoryMutation.reset();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Memory</DialogTitle>
|
||||
<DialogDescription>
|
||||
Memories are automatically included in every conversation to help Dexto
|
||||
remember your preferences and important information.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{createMemoryMutation.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{createMemoryMutation.error instanceof Error
|
||||
? createMemoryMutation.error.message
|
||||
: 'Failed to create memory'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memory-content">Memory Content *</Label>
|
||||
<Textarea
|
||||
id="memory-content"
|
||||
placeholder="e.g., I prefer concise responses without explanations"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[100px] resize-none"
|
||||
disabled={createMemoryMutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{content.length} / 10,000 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memory-tags">Tags (optional)</Label>
|
||||
<Input
|
||||
id="memory-tags"
|
||||
placeholder="e.g., preferences, work, coding-style"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
disabled={createMemoryMutation.isPending}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Comma-separated tags for organizing memories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={createMemoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createMemoryMutation.isPending || !content.trim()}
|
||||
>
|
||||
{createMemoryMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Memory'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
300
dexto/packages/webui/components/CreatePromptModal.tsx
Normal file
300
dexto/packages/webui/components/CreatePromptModal.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCreatePrompt } from './hooks/usePrompts';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Loader2, Trash2, Upload, FileText } from 'lucide-react';
|
||||
|
||||
interface CreatePromptModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (prompt: { name: string }) => void;
|
||||
}
|
||||
|
||||
interface ResourcePayload {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export default function CreatePromptModal({ open, onClose, onCreated }: CreatePromptModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [resource, setResource] = useState<ResourcePayload | null>(null);
|
||||
const [resourcePreview, setResourcePreview] = useState<string | null>(null);
|
||||
const [resourceName, setResourceName] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const createPromptMutation = useCreatePrompt();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setContent('');
|
||||
setResource(null);
|
||||
setResourcePreview(null);
|
||||
setResourceName(null);
|
||||
setIsDragOver(false);
|
||||
createPromptMutation.reset();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
try {
|
||||
const data = await readFileAsDataUrl(file);
|
||||
setResource({
|
||||
data,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
filename: file.name,
|
||||
});
|
||||
setResourcePreview(data);
|
||||
setResourceName(file.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to read file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResourceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setResource(null);
|
||||
setResourcePreview(null);
|
||||
setResourceName(null);
|
||||
return;
|
||||
}
|
||||
await handleFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeResource = () => {
|
||||
setResource(null);
|
||||
setResourcePreview(null);
|
||||
setResourceName(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
content,
|
||||
resource: resource || undefined,
|
||||
};
|
||||
|
||||
createPromptMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.prompt) {
|
||||
onCreated({ name: data.prompt.name });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Custom Prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define reusable prompt text and optionally attach a supporting resource
|
||||
file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{createPromptMutation.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{createPromptMutation.error instanceof Error
|
||||
? createPromptMutation.error.message
|
||||
: 'Failed to create prompt'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-name">Prompt Name</Label>
|
||||
<Input
|
||||
id="prompt-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="research-summary"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Use lowercase letters, numbers, or hyphens. This becomes your
|
||||
/prompt command.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-title">Title</Label>
|
||||
<Input
|
||||
id="prompt-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Research Summary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-description">Description</Label>
|
||||
<Input
|
||||
id="prompt-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Summarize research papers with key findings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-content">Prompt Content</Label>
|
||||
<Textarea
|
||||
id="prompt-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write the instructions for this prompt..."
|
||||
className="min-h-[160px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Attach resource (optional)</Label>
|
||||
{resourcePreview ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed border-border/60 bg-muted/40 px-4 py-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<FileText className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
Resource
|
||||
</Badge>
|
||||
{resourceName || 'Attached file'}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={removeResource}>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`relative rounded-lg border-2 border-dashed transition-colors ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border/60 hover:border-border/80'
|
||||
} px-6 py-8 text-center`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleResourceChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept="*/*"
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<Upload
|
||||
className={`h-8 w-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">
|
||||
{isDragOver ? 'Drop file here' : 'Click to upload'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
or drag and drop
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Any file type supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
The resource will be stored securely and referenced when this prompt is
|
||||
used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={createPromptMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createPromptMutation.isPending}>
|
||||
{createPromptMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Prompt'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
124
dexto/packages/webui/components/ErrorBanner.tsx
Normal file
124
dexto/packages/webui/components/ErrorBanner.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { type ErrorMessage } from '@/lib/stores/chatStore';
|
||||
import type { Issue } from '@dexto/core';
|
||||
import { CopyButton } from './ui/copy-button';
|
||||
import { SpeakButton } from './ui/speak-button';
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: ErrorMessage;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Extract the actual detailed validation issues from the hierarchical structure
|
||||
// The server sends: hierarchicalError.issues[0].context.detailedIssues = [actual validation issues]
|
||||
// TODO: Update this to just print the entire error object
|
||||
const firstIssue = error.detailedIssues?.[0];
|
||||
const detailedIssues =
|
||||
firstIssue?.context &&
|
||||
typeof firstIssue.context === 'object' &&
|
||||
'detailedIssues' in firstIssue.context
|
||||
? (firstIssue.context as { detailedIssues: Issue[] }).detailedIssues
|
||||
: [];
|
||||
|
||||
// Get the text to copy - include both top-level and detailed messages with full context
|
||||
const fullErrorText =
|
||||
detailedIssues.length > 0
|
||||
? `${error.message}\n\nDetails:\n${detailedIssues
|
||||
.map((issue: Issue) => {
|
||||
let text = issue.message;
|
||||
if (issue.context) {
|
||||
const contextStr =
|
||||
typeof issue.context === 'string'
|
||||
? issue.context
|
||||
: JSON.stringify(issue.context, null, 2);
|
||||
text += `\nContext: ${contextStr}`;
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.join('\n\n')}`
|
||||
: error.message;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg p-4 mb-4 border shadow-sm bg-destructive/10 border-destructive/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-destructive">Error</h3>
|
||||
{error.context && (
|
||||
<span className="text-xs bg-destructive/15 text-destructive px-2 py-0.5 rounded-full">
|
||||
{error.context}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{detailedIssues.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-destructive/15 rounded text-destructive"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CopyButton
|
||||
value={fullErrorText}
|
||||
tooltip="Copy error"
|
||||
className="p-1 hover:bg-destructive/15 rounded text-destructive"
|
||||
size={16}
|
||||
/>
|
||||
|
||||
<SpeakButton
|
||||
value={fullErrorText}
|
||||
tooltip="Read error"
|
||||
className="p-1 hover:bg-destructive/15 rounded text-destructive"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 hover:bg-destructive/15 rounded text-destructive"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main error message */}
|
||||
<div className="mt-2 text-sm text-destructive">{error.message}</div>
|
||||
|
||||
{isExpanded && detailedIssues.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-destructive bg-destructive/10 p-3 rounded border border-destructive/30 overflow-auto max-h-60">
|
||||
{detailedIssues.map((issue: Issue, index: number) => (
|
||||
<div key={index} className="mb-2 last:mb-0">
|
||||
<div className="font-medium">{issue.message}</div>
|
||||
{issue.context != null && (
|
||||
<div className="text-destructive/70 mt-1">
|
||||
{typeof issue.context === 'string'
|
||||
? issue.context
|
||||
: JSON.stringify(issue.context, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
dexto/packages/webui/components/GlobalSearchModal.tsx
Normal file
291
dexto/packages/webui/components/GlobalSearchModal.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSearchMessages, type SearchResult } from './hooks/useSearch';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Search, MessageSquare, User, Bot, Settings, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from './ui/badge';
|
||||
|
||||
interface GlobalSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNavigateToSession: (sessionId: string, messageIndex?: number) => void;
|
||||
}
|
||||
|
||||
export default function GlobalSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onNavigateToSession,
|
||||
}: GlobalSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery] = useDebounce(searchQuery, 300);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Use TanStack Query for search with debouncing
|
||||
const { data, isLoading, error } = useSearchMessages(debouncedQuery, undefined, 10, isOpen);
|
||||
|
||||
const results = data?.results || [];
|
||||
const searchError = error?.message ?? null;
|
||||
|
||||
// Clamp selectedIndex when results change to prevent out-of-bounds selection
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= results.length && results.length > 0) {
|
||||
setSelectedIndex(results.length - 1);
|
||||
} else if (results.length === 0) {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [results.length, selectedIndex]);
|
||||
|
||||
const handleResultClick = useCallback(
|
||||
(result: SearchResult) => {
|
||||
onNavigateToSession(result.sessionId, result.messageIndex);
|
||||
onClose();
|
||||
},
|
||||
[onNavigateToSession, onClose]
|
||||
);
|
||||
|
||||
// Reset when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Keyboard navigation (using react-hotkeys-hook)
|
||||
// ArrowDown to navigate down in results
|
||||
useHotkeys(
|
||||
'down',
|
||||
() => {
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
||||
},
|
||||
{ enabled: isOpen, preventDefault: true },
|
||||
[isOpen, results.length]
|
||||
);
|
||||
|
||||
// ArrowUp to navigate up in results
|
||||
useHotkeys(
|
||||
'up',
|
||||
() => {
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
},
|
||||
{ enabled: isOpen, preventDefault: true },
|
||||
[isOpen]
|
||||
);
|
||||
|
||||
// Enter to select current result
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
if (results[selectedIndex]) {
|
||||
handleResultClick(results[selectedIndex]);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
},
|
||||
{ enabled: isOpen, preventDefault: true },
|
||||
[isOpen, results, selectedIndex, handleResultClick]
|
||||
);
|
||||
|
||||
// Escape to close modal
|
||||
useHotkeys(
|
||||
'escape',
|
||||
() => {
|
||||
onClose();
|
||||
},
|
||||
{ enabled: isOpen, preventDefault: true },
|
||||
[isOpen, onClose]
|
||||
);
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return <User className="w-4 h-4" />;
|
||||
case 'assistant':
|
||||
return <Bot className="w-4 h-4" />;
|
||||
case 'system':
|
||||
return <Settings className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'assistant':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'system':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text;
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="bg-yellow-200 dark:bg-yellow-800 font-medium rounded px-1"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/10 backdrop-blur-[2px]" onClick={onClose} />
|
||||
{/* Search popover */}
|
||||
<div className="fixed left-1/2 top-[15%] -translate-x-1/2 z-50 w-full max-w-2xl bg-popover/70 backdrop-blur-xl border border-border/30 rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="flex flex-col max-h-[70vh]">
|
||||
{/* Search Header */}
|
||||
<div className="p-4 border-b border-border/30">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 text-lg border-0 shadow-none focus-visible:ring-0 bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mr-3" />
|
||||
<span className="text-muted-foreground">Searching...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 text-destructive opacity-50" />
|
||||
<p className="text-destructive font-medium">Search Error</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{searchError}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Try again or check your connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full max-h-[calc(70vh-80px)]">
|
||||
<div className="p-2">
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors',
|
||||
index === selectedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs',
|
||||
getRoleColor(result.message.role)
|
||||
)}
|
||||
>
|
||||
{getRoleIcon(result.message.role)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
{result.sessionId.length > 20
|
||||
? `${result.sessionId.slice(0, 20)}...`
|
||||
: result.sessionId}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{result.message.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground line-clamp-2">
|
||||
{highlightText(
|
||||
result.context,
|
||||
debouncedQuery
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : debouncedQuery ? (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
No messages found matching your search.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Try different keywords.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
Start typing to search your conversations.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
|
||||
↑
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
|
||||
↓
|
||||
</kbd>
|
||||
<span>to navigate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
|
||||
Enter
|
||||
</kbd>
|
||||
<span>to select</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
|
||||
Esc
|
||||
</kbd>
|
||||
<span>to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
326
dexto/packages/webui/components/InlineApprovalCard.tsx
Normal file
326
dexto/packages/webui/components/InlineApprovalCard.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { AlertTriangle, Wrench } from 'lucide-react';
|
||||
import type { ApprovalEvent } from './ToolConfirmationHandler';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { ApprovalType } from '@dexto/core';
|
||||
|
||||
interface InlineApprovalCardProps {
|
||||
approval: ApprovalEvent;
|
||||
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
export function InlineApprovalCard({ approval, onApprove, onDeny }: InlineApprovalCardProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
|
||||
// Update form field value
|
||||
const updateFormField = (fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
if (formErrors[fieldName]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render form field based on JSON Schema field type
|
||||
const renderFormField = (fieldName: string, fieldSchema: JSONSchema7, isRequired: boolean) => {
|
||||
const fieldType = fieldSchema.type || 'string';
|
||||
const fieldValue = formData[fieldName];
|
||||
const hasError = !!formErrors[fieldName];
|
||||
// Use title if available, fallback to fieldName
|
||||
const label = fieldSchema.title || fieldName;
|
||||
|
||||
if (fieldType === 'boolean') {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={fieldName}
|
||||
checked={fieldValue === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFormField(fieldName, checked === true)
|
||||
}
|
||||
/>
|
||||
<label htmlFor={fieldName} className="text-sm font-medium">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'number' || fieldType === 'integer') {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-sm font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
|
||||
)}
|
||||
<input
|
||||
id={fieldName}
|
||||
type="number"
|
||||
step={fieldType === 'integer' ? '1' : 'any'}
|
||||
value={typeof fieldValue === 'number' ? fieldValue : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const nextValue = raw === '' ? undefined : Number(raw);
|
||||
updateFormField(fieldName, nextValue);
|
||||
}}
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
}`}
|
||||
placeholder={isRequired ? 'Required' : 'Optional'}
|
||||
/>
|
||||
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldSchema.enum && Array.isArray(fieldSchema.enum)) {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-sm font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
|
||||
)}
|
||||
<select
|
||||
id={fieldName}
|
||||
value={
|
||||
fieldValue !== undefined && fieldValue !== null
|
||||
? String(fieldValue)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const selected = e.target.value;
|
||||
if (selected === '') {
|
||||
updateFormField(fieldName, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = (fieldSchema.enum as unknown[])?.find(
|
||||
(option) => String(option) === selected
|
||||
);
|
||||
updateFormField(fieldName, matched ?? selected);
|
||||
}}
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{(fieldSchema.enum as unknown[])?.map((option) => (
|
||||
<option key={String(option)} value={String(option)}>
|
||||
{String(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to string input
|
||||
return (
|
||||
<div key={fieldName} className="space-y-1">
|
||||
<label htmlFor={fieldName} className="text-sm font-medium block">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
|
||||
)}
|
||||
<input
|
||||
id={fieldName}
|
||||
type="text"
|
||||
value={
|
||||
fieldValue !== undefined &&
|
||||
fieldValue !== null &&
|
||||
typeof fieldValue !== 'object'
|
||||
? String(fieldValue)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => updateFormField(fieldName, e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
|
||||
hasError ? 'border-red-500' : 'border-border'
|
||||
}`}
|
||||
placeholder={isRequired ? 'Required' : 'Optional'}
|
||||
/>
|
||||
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
if (approval.type === ApprovalType.ELICITATION) {
|
||||
// Validate form - metadata is typed as ElicitationMetadata after type check
|
||||
const { schema } = approval.metadata;
|
||||
const required = (schema.required as string[]) || [];
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const fieldName of required) {
|
||||
const value = formData[fieldName];
|
||||
const isEmptyString = typeof value === 'string' && value.trim() === '';
|
||||
if (value === undefined || value === null || isEmptyString) {
|
||||
errors[fieldName] = 'This field is required';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
onApprove(formData);
|
||||
} else {
|
||||
onApprove(undefined, rememberChoice);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">
|
||||
{approval.type === ApprovalType.ELICITATION
|
||||
? 'Information Request'
|
||||
: 'Approval Required'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{approval.type === ApprovalType.COMMAND_CONFIRMATION ? (
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="font-medium text-sm break-words min-w-0">
|
||||
Tool: {approval.metadata.toolName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-sm block mb-2">Command:</span>
|
||||
<pre className="bg-muted/50 p-3 rounded-md text-xs overflow-auto max-h-40 border border-border break-words whitespace-pre-wrap max-w-full text-red-600 dark:text-red-400">
|
||||
{approval.metadata.command}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{approval.metadata.originalCommand &&
|
||||
approval.metadata.originalCommand !== approval.metadata.command && (
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Original: {approval.metadata.originalCommand}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-md border border-yellow-200 dark:border-yellow-800">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
This command requires approval because it may modify your system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : approval.type === ApprovalType.ELICITATION ? (
|
||||
<div className="space-y-4 min-w-0">
|
||||
<div className="bg-muted/50 p-3 rounded-md border border-border min-w-0">
|
||||
<p className="text-sm font-medium mb-1 break-words">
|
||||
{approval.metadata.prompt}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
From: {approval.metadata.serverName || 'Dexto Agent'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{(() => {
|
||||
const { schema } = approval.metadata;
|
||||
if (!schema?.properties || typeof schema.properties !== 'object') {
|
||||
return (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
Invalid form schema
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const required = (schema.required as string[]) || [];
|
||||
const properties = schema.properties;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(properties).map(([fieldName, fieldSchema]) => {
|
||||
const isRequired = required.includes(fieldName);
|
||||
return renderFormField(
|
||||
fieldName,
|
||||
fieldSchema as JSONSchema7,
|
||||
isRequired
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : approval.type === ApprovalType.TOOL_CONFIRMATION ? (
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="font-medium text-sm break-words min-w-0">
|
||||
Tool: {approval.metadata.toolName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{approval.metadata.description && (
|
||||
<p className="text-sm break-words">{approval.metadata.description}</p>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-sm block mb-2">Arguments:</span>
|
||||
<pre className="bg-muted/50 p-3 rounded-md text-xs overflow-auto max-h-40 border border-border break-words whitespace-pre-wrap max-w-full">
|
||||
{JSON.stringify(approval.metadata.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Only show "Remember choice" for tool confirmations, not command confirmations */}
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked === true)}
|
||||
/>
|
||||
<label htmlFor="remember" className="text-sm">
|
||||
Remember this choice for this session
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-3 border-t border-border">
|
||||
<Button variant="outline" onClick={onDeny} size="sm">
|
||||
{approval.type === ApprovalType.ELICITATION ? 'Decline' : 'Deny'}
|
||||
</Button>
|
||||
<Button onClick={handleApprove} size="sm">
|
||||
{approval.type === ApprovalType.ELICITATION ? 'Submit' : 'Approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1176
dexto/packages/webui/components/InputArea.tsx
Normal file
1176
dexto/packages/webui/components/InputArea.tsx
Normal file
File diff suppressed because it is too large
Load Diff
275
dexto/packages/webui/components/MemoryPanel.tsx
Normal file
275
dexto/packages/webui/components/MemoryPanel.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMemories, useDeleteMemory, type Memory } from './hooks/useMemories';
|
||||
import { formatRelativeTime } from '@/lib/date-utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Brain, Plus, Trash2, Calendar, Tag, AlertTriangle, RefreshCw, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import CreateMemoryModal from './CreateMemoryModal';
|
||||
|
||||
interface MemoryPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
variant?: 'inline' | 'modal';
|
||||
}
|
||||
|
||||
export default function MemoryPanel({ isOpen, onClose, variant = 'modal' }: MemoryPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedMemoryForDelete, setSelectedMemoryForDelete] = useState<Memory | null>(null);
|
||||
|
||||
const { data: memories = [], isLoading: loading, error } = useMemories(isOpen);
|
||||
|
||||
const deleteMemoryMutation = useDeleteMemory();
|
||||
|
||||
const handleDeleteMemory = async (memoryId: string) => {
|
||||
await deleteMemoryMutation.mutateAsync({ memoryId });
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedMemoryForDelete(null);
|
||||
};
|
||||
|
||||
const truncateContent = (content: string, maxLength: number = 120) => {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
// Filter memories based on search query
|
||||
const filteredMemories = memories.filter((memory) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
memory.content.toLowerCase().includes(query) ||
|
||||
memory.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
<h2 className="text-base font-semibold">Memories</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{memories.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar with Create Button */}
|
||||
<div className="p-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search memories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
aria-label="Create new memory"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create Memory</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memories List */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : filteredMemories.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Brain className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
{searchQuery.trim() ? (
|
||||
<>
|
||||
<p>No memories found</p>
|
||||
<p className="text-sm">Try a different search term</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>No memories yet</p>
|
||||
<p className="text-sm">
|
||||
Type # in chat or use the + button above
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredMemories.map((memory) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className={cn(
|
||||
'group p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all',
|
||||
memory.metadata?.pinned && 'ring-1 ring-primary/30 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Content */}
|
||||
<p className="text-sm leading-relaxed">
|
||||
{truncateContent(memory.content)}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{memory.tags && memory.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
{memory.tags.map((tag, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatRelativeTime(memory.updatedAt)}</span>
|
||||
</div>
|
||||
{memory.metadata?.source && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{memory.metadata.source}
|
||||
</Badge>
|
||||
)}
|
||||
{memory.metadata?.pinned && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
Pinned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedMemoryForDelete(memory);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete memory"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Create Memory Modal */}
|
||||
<CreateMemoryModal open={isCreateModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>Delete Memory</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete this memory. This action cannot be undone.
|
||||
{selectedMemoryForDelete && (
|
||||
<span className="block mt-2 text-sm font-medium max-h-20 overflow-y-auto">
|
||||
{truncateContent(selectedMemoryForDelete.content, 100)}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
selectedMemoryForDelete &&
|
||||
handleDeleteMemory(selectedMemoryForDelete.id)
|
||||
}
|
||||
disabled={deleteMemoryMutation.isPending}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>
|
||||
{deleteMemoryMutation.isPending ? 'Deleting...' : 'Delete Memory'}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
return <div className="h-full">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg h-[600px] flex flex-col p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Memories</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1618
dexto/packages/webui/components/MessageList.tsx
Normal file
1618
dexto/packages/webui/components/MessageList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
102
dexto/packages/webui/components/ModelPicker/CapabilityIcons.tsx
Normal file
102
dexto/packages/webui/components/ModelPicker/CapabilityIcons.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Lock, Eye, FileText, Mic, Brain } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ModelInfo } from './types';
|
||||
|
||||
interface CapabilityIconsProps {
|
||||
supportedFileTypes: ModelInfo['supportedFileTypes'];
|
||||
hasApiKey: boolean;
|
||||
showReasoning?: boolean;
|
||||
showLockIcon?: boolean;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
interface CapabilityBadgeProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
variant?: 'default' | 'warning' | 'success' | 'info';
|
||||
}
|
||||
|
||||
function CapabilityBadge({ icon, label, variant = 'default' }: CapabilityBadgeProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-muted/80 text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
warning: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20',
|
||||
success: 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20',
|
||||
info: 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200 cursor-default',
|
||||
variantStyles[variant]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilityIcons({
|
||||
supportedFileTypes,
|
||||
hasApiKey,
|
||||
showReasoning,
|
||||
showLockIcon = true,
|
||||
className,
|
||||
size = 'sm',
|
||||
}: CapabilityIconsProps) {
|
||||
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{supportedFileTypes?.includes('image') && (
|
||||
<CapabilityBadge
|
||||
icon={<Eye className={iconSize} />}
|
||||
label="Vision / Image support"
|
||||
variant="success"
|
||||
/>
|
||||
)}
|
||||
|
||||
{supportedFileTypes?.includes('pdf') && (
|
||||
<CapabilityBadge
|
||||
icon={<FileText className={iconSize} />}
|
||||
label="PDF support"
|
||||
variant="info"
|
||||
/>
|
||||
)}
|
||||
|
||||
{supportedFileTypes?.includes('audio') && (
|
||||
<CapabilityBadge
|
||||
icon={<Mic className={iconSize} />}
|
||||
label="Audio support"
|
||||
variant="info"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReasoning && (
|
||||
<CapabilityBadge
|
||||
icon={<Brain className={iconSize} />}
|
||||
label="Extended thinking"
|
||||
variant="default"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLockIcon && !hasApiKey && (
|
||||
<CapabilityBadge
|
||||
icon={<Lock className={iconSize} />}
|
||||
label="Click to add API key"
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
dexto/packages/webui/components/ModelPicker/CompactModelCard.tsx
Normal file
175
dexto/packages/webui/components/ModelPicker/CompactModelCard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Star, HelpCircle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { LLMProvider } from '@dexto/core';
|
||||
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines } from './constants';
|
||||
import { CapabilityIcons } from './CapabilityIcons';
|
||||
import type { ModelInfo, ProviderCatalog } from './types';
|
||||
|
||||
interface CompactModelCardProps {
|
||||
provider: LLMProvider;
|
||||
model: ModelInfo;
|
||||
providerInfo: ProviderCatalog;
|
||||
isFavorite: boolean;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
}
|
||||
|
||||
// Provider display name mapping
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: 'Claude',
|
||||
google: 'Gemini',
|
||||
openai: 'GPT',
|
||||
groq: 'Groq',
|
||||
xai: 'Grok',
|
||||
cohere: 'Cohere',
|
||||
'openai-compatible': 'Custom',
|
||||
dexto: 'Dexto',
|
||||
};
|
||||
|
||||
// Providers that have multi-vendor models (don't strip provider prefixes from display name)
|
||||
const MULTI_VENDOR_PROVIDERS = new Set([
|
||||
'openrouter',
|
||||
'dexto',
|
||||
'openai-compatible',
|
||||
'litellm',
|
||||
'glama',
|
||||
'bedrock',
|
||||
'vertex',
|
||||
]);
|
||||
|
||||
export function CompactModelCard({
|
||||
provider,
|
||||
model,
|
||||
providerInfo,
|
||||
isFavorite,
|
||||
isActive,
|
||||
onClick,
|
||||
onToggleFavorite,
|
||||
}: CompactModelCardProps) {
|
||||
const displayName = model.displayName || model.name;
|
||||
const hasApiKey = providerInfo.hasApiKey;
|
||||
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||
|
||||
// Build description for tooltip
|
||||
const priceLines = formatPricingLines(model.pricing || undefined);
|
||||
const descriptionLines = [
|
||||
`Provider: ${providerInfo.name}`,
|
||||
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
|
||||
Array.isArray(model.supportedFileTypes) &&
|
||||
model.supportedFileTypes.length > 0 &&
|
||||
`Supports: ${model.supportedFileTypes.join(', ')}`,
|
||||
!hasApiKey && 'API key required',
|
||||
...priceLines,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && target.closest('button')) return;
|
||||
|
||||
const isEnter = event.key === 'Enter';
|
||||
const isSpace =
|
||||
event.key === ' ' ||
|
||||
event.key === 'Spacebar' ||
|
||||
event.code === 'Space';
|
||||
if (!isEnter && !isSpace) return;
|
||||
if (isSpace) event.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2.5 px-3 py-2 rounded-xl border transition-all duration-150 cursor-pointer group whitespace-nowrap',
|
||||
'hover:bg-accent/50 hover:border-primary/30',
|
||||
isActive
|
||||
? 'bg-primary/10 border-primary/40 shadow-sm'
|
||||
: 'border-border/40 bg-card/60',
|
||||
!hasApiKey && 'opacity-70'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Provider Logo */}
|
||||
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
{PROVIDER_LOGOS[provider] ? (
|
||||
<img
|
||||
src={PROVIDER_LOGOS[provider]}
|
||||
alt={`${provider} logo`}
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn(
|
||||
'object-contain',
|
||||
needsDarkModeInversion(provider) &&
|
||||
'dark:invert dark:brightness-0 dark:contrast-200'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Name */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold text-foreground leading-tight truncate">
|
||||
{providerName}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||
{MULTI_VENDOR_PROVIDERS.has(provider)
|
||||
? displayName
|
||||
: displayName.replace(
|
||||
new RegExp(`^${providerName}\\s*`, 'i'),
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Capability Icons */}
|
||||
<CapabilityIcons
|
||||
supportedFileTypes={model.supportedFileTypes}
|
||||
hasApiKey={hasApiKey}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Favorite Star */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite();
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 p-0.5 rounded-full transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isFavorite && 'opacity-100'
|
||||
)}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-all',
|
||||
isFavorite
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground/50 hover:text-yellow-400'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="text-xs space-y-0.5">
|
||||
{descriptionLines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
1336
dexto/packages/webui/components/ModelPicker/CustomModelForms.tsx
Normal file
1336
dexto/packages/webui/components/ModelPicker/CustomModelForms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
318
dexto/packages/webui/components/ModelPicker/ModelCard.tsx
Normal file
318
dexto/packages/webui/components/ModelPicker/ModelCard.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React from 'react';
|
||||
import { Star, HelpCircle, Lock, X, Pencil } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { LLMProvider } from '@dexto/core';
|
||||
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines, hasLogo } from './constants';
|
||||
import { CapabilityIcons } from './CapabilityIcons';
|
||||
import type { ModelInfo, ProviderCatalog } from './types';
|
||||
|
||||
interface ModelCardProps {
|
||||
provider: LLMProvider;
|
||||
model: ModelInfo;
|
||||
providerInfo?: ProviderCatalog;
|
||||
isFavorite: boolean;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isCustom?: boolean;
|
||||
/** Installed local model (downloaded via CLI) */
|
||||
isInstalled?: boolean;
|
||||
}
|
||||
|
||||
// Provider display name mapping
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: 'Claude',
|
||||
google: 'Gemini',
|
||||
openai: 'GPT',
|
||||
groq: 'Groq',
|
||||
xai: 'Grok',
|
||||
cohere: 'Cohere',
|
||||
openrouter: 'OpenRouter',
|
||||
'openai-compatible': 'Custom',
|
||||
litellm: 'LiteLLM',
|
||||
glama: 'Glama',
|
||||
local: 'Local',
|
||||
ollama: 'Ollama',
|
||||
dexto: 'Dexto',
|
||||
};
|
||||
|
||||
// Parse display name into provider and model parts
|
||||
function parseModelName(
|
||||
displayName: string,
|
||||
provider: string
|
||||
): { providerName: string; modelName: string; suffix?: string } {
|
||||
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||
|
||||
// For multi-vendor or custom model providers, show the full display name without parsing
|
||||
if (
|
||||
provider === 'openrouter' ||
|
||||
provider === 'dexto' ||
|
||||
provider === 'openai-compatible' ||
|
||||
provider === 'litellm' ||
|
||||
provider === 'glama' ||
|
||||
provider === 'bedrock' ||
|
||||
provider === 'vertex'
|
||||
) {
|
||||
return { providerName, modelName: displayName };
|
||||
}
|
||||
|
||||
// Extract suffix like (Reasoning) if present
|
||||
const suffixMatch = displayName.match(/\(([^)]+)\)$/);
|
||||
const suffix = suffixMatch ? suffixMatch[1] : undefined;
|
||||
const nameWithoutSuffix = suffix ? displayName.replace(/\s*\([^)]+\)$/, '') : displayName;
|
||||
|
||||
// Try to extract model variant (remove provider prefix if present)
|
||||
let modelName = nameWithoutSuffix;
|
||||
const lowerName = nameWithoutSuffix.toLowerCase();
|
||||
const lowerProvider = providerName.toLowerCase();
|
||||
|
||||
if (lowerName.startsWith(lowerProvider)) {
|
||||
modelName = nameWithoutSuffix.slice(providerName.length).trim();
|
||||
}
|
||||
|
||||
// Clean up common patterns
|
||||
modelName = modelName.replace(/^[-\s]+/, '').replace(/^(claude|gemini|gpt|grok)\s*/i, '');
|
||||
|
||||
return { providerName, modelName: modelName || nameWithoutSuffix, suffix };
|
||||
}
|
||||
|
||||
export function ModelCard({
|
||||
provider,
|
||||
model,
|
||||
providerInfo,
|
||||
isFavorite,
|
||||
isActive,
|
||||
onClick,
|
||||
onToggleFavorite,
|
||||
onDelete,
|
||||
onEdit,
|
||||
size = 'md',
|
||||
isCustom = false,
|
||||
isInstalled = false,
|
||||
}: ModelCardProps) {
|
||||
const displayName = model.displayName || model.name;
|
||||
// Local/ollama/installed models don't need API keys
|
||||
// Custom models are user-configured, so don't show lock (they handle their own auth)
|
||||
const noApiKeyNeeded = isInstalled || isCustom || provider === 'local' || provider === 'ollama';
|
||||
const hasApiKey = noApiKeyNeeded || (providerInfo?.hasApiKey ?? false);
|
||||
const { providerName, modelName, suffix } = parseModelName(displayName, provider);
|
||||
|
||||
// Build description lines for tooltip
|
||||
const priceLines = formatPricingLines(model.pricing || undefined);
|
||||
const descriptionLines = [
|
||||
`Model: ${displayName}`,
|
||||
isInstalled
|
||||
? 'Installed via CLI'
|
||||
: provider === 'local'
|
||||
? 'Local Model'
|
||||
: provider === 'openai-compatible'
|
||||
? 'Custom Model'
|
||||
: `Provider: ${providerInfo?.name}`,
|
||||
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
|
||||
Array.isArray(model.supportedFileTypes) &&
|
||||
model.supportedFileTypes.length > 0 &&
|
||||
`Supports: ${model.supportedFileTypes.join(', ')}`,
|
||||
!hasApiKey && 'API key required (click to add)',
|
||||
...priceLines,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-4 h-[200px] w-full',
|
||||
md: 'px-3 py-5 h-[230px] w-full',
|
||||
lg: 'px-4 py-6 h-[275px] w-full',
|
||||
};
|
||||
|
||||
const logoSizes = {
|
||||
sm: { width: 36, height: 36, container: 'w-10 h-10' },
|
||||
md: { width: 48, height: 48, container: 'w-14 h-14' },
|
||||
lg: { width: 60, height: 60, container: 'w-16 h-16' },
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && target.closest('button')) return;
|
||||
|
||||
const isEnter = event.key === 'Enter';
|
||||
const isSpace =
|
||||
event.key === ' ' ||
|
||||
event.key === 'Spacebar' ||
|
||||
event.code === 'Space';
|
||||
if (!isEnter && !isSpace) return;
|
||||
if (isSpace) event.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-2xl border-2 transition-all duration-200 cursor-pointer group overflow-hidden',
|
||||
sizeClasses[size],
|
||||
'hover:bg-accent/40 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5',
|
||||
isActive
|
||||
? 'bg-primary/10 border-primary shadow-lg shadow-primary/10'
|
||||
: 'border-border/50 bg-card/60 backdrop-blur-sm',
|
||||
!hasApiKey && 'opacity-70'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Lock Icon - Top Left (when no API key) */}
|
||||
{!hasApiKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="absolute top-2 left-2 p-1.5 rounded-full bg-amber-500/20 z-10">
|
||||
<Lock className="h-3.5 w-3.5 text-amber-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Click to add API key
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Action Buttons - Top Left for custom/installed models */}
|
||||
{(isCustom || isInstalled) && (onEdit || onDelete) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all duration-200',
|
||||
'hover:bg-primary/20 hover:scale-110 active:scale-95'
|
||||
)}
|
||||
aria-label={
|
||||
isInstalled
|
||||
? 'Edit installed model'
|
||||
: 'Edit custom model'
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-muted-foreground/60 hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all duration-200',
|
||||
'hover:bg-destructive/20 hover:scale-110 active:scale-95'
|
||||
)}
|
||||
aria-label={
|
||||
isInstalled
|
||||
? 'Delete installed model'
|
||||
: 'Delete custom model'
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground/60 hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorite Star - Top Right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 p-1.5 rounded-full transition-all duration-200 z-10',
|
||||
'hover:bg-yellow-500/20 hover:scale-110 active:scale-95',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isFavorite && 'opacity-100'
|
||||
)}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-4 w-4 transition-all duration-200',
|
||||
isFavorite
|
||||
? 'fill-yellow-400 text-yellow-400 drop-shadow-[0_0_3px_rgba(250,204,21,0.5)]'
|
||||
: 'text-muted-foreground/60 hover:text-yellow-400'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Provider Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-xl bg-muted/60 mb-1.5',
|
||||
logoSizes[size].container
|
||||
)}
|
||||
>
|
||||
{hasLogo(provider) ? (
|
||||
<img
|
||||
src={PROVIDER_LOGOS[provider]}
|
||||
alt={`${provider} logo`}
|
||||
width={logoSizes[size].width}
|
||||
height={logoSizes[size].height}
|
||||
className={cn(
|
||||
'object-contain',
|
||||
needsDarkModeInversion(provider) &&
|
||||
'dark:invert dark:brightness-0 dark:contrast-200'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Name */}
|
||||
<div className="text-center flex-1 flex flex-col min-w-0 w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'font-bold text-foreground leading-tight',
|
||||
size === 'sm' ? 'text-base' : 'text-lg'
|
||||
)}
|
||||
>
|
||||
{providerName}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground leading-tight mt-0.5 line-clamp-3',
|
||||
size === 'sm' ? 'text-sm' : 'text-base'
|
||||
)}
|
||||
>
|
||||
{modelName}
|
||||
</div>
|
||||
{suffix && (
|
||||
<div className="text-xs text-primary/90 font-medium mt-1">
|
||||
({suffix})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Capability Icons - fixed height to ensure consistent card layout */}
|
||||
<div className="mt-auto pt-2 h-8 flex items-center justify-center">
|
||||
<CapabilityIcons
|
||||
supportedFileTypes={model.supportedFileTypes}
|
||||
hasApiKey={hasApiKey}
|
||||
showLockIcon={false}
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-0.5">
|
||||
{descriptionLines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
1262
dexto/packages/webui/components/ModelPicker/ModelPickerModal.tsx
Normal file
1262
dexto/packages/webui/components/ModelPicker/ModelPickerModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
237
dexto/packages/webui/components/ModelPicker/ProviderSection.tsx
Normal file
237
dexto/packages/webui/components/ModelPicker/ProviderSection.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Star, HelpCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
|
||||
import type { ProviderCatalog, ModelInfo } from './types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { LLMProvider } from '@dexto/core';
|
||||
import {
|
||||
PROVIDER_LOGOS,
|
||||
needsDarkModeInversion,
|
||||
PROVIDER_PRICING_URLS,
|
||||
formatPricingLines,
|
||||
} from './constants';
|
||||
import { CapabilityIcons } from './CapabilityIcons';
|
||||
|
||||
type Props = {
|
||||
providerId: LLMProvider;
|
||||
provider: ProviderCatalog;
|
||||
models: ModelInfo[];
|
||||
favorites: string[];
|
||||
currentModel?: { provider: string; model: string; displayName?: string };
|
||||
onToggleFavorite: (providerId: LLMProvider, modelName: string) => void;
|
||||
onUse: (providerId: LLMProvider, model: ModelInfo) => void;
|
||||
defaultExpanded?: boolean;
|
||||
};
|
||||
|
||||
export function ProviderSection({
|
||||
providerId,
|
||||
provider,
|
||||
models,
|
||||
favorites,
|
||||
currentModel,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
defaultExpanded = true,
|
||||
}: Props) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const isCurrentModel = (modelName: string) =>
|
||||
currentModel?.provider === providerId && currentModel?.model === modelName;
|
||||
|
||||
const isFavorite = (modelName: string) => favorites.includes(`${providerId}|${modelName}`);
|
||||
|
||||
const hasActiveModel = models.some((m) => isCurrentModel(m.name));
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-2">
|
||||
{/* Provider Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200',
|
||||
'hover:bg-accent/50 group',
|
||||
hasActiveModel && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-muted/50">
|
||||
{PROVIDER_LOGOS[providerId] ? (
|
||||
<img
|
||||
src={PROVIDER_LOGOS[providerId]}
|
||||
alt={`${providerId} logo`}
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn(
|
||||
'object-contain',
|
||||
needsDarkModeInversion(providerId) &&
|
||||
'dark:invert dark:brightness-0 dark:contrast-200'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-semibold">{provider.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{models.length} model{models.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!provider.hasApiKey && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-amber-500/10 text-amber-500">
|
||||
API Key Required
|
||||
</span>
|
||||
)}
|
||||
{PROVIDER_PRICING_URLS[providerId] && (
|
||||
<a
|
||||
href={PROVIDER_PRICING_URLS[providerId]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Pricing
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Models List */}
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pl-2">
|
||||
{models.map((model) => {
|
||||
const displayName = model.displayName || model.name;
|
||||
const isActive = isCurrentModel(model.name);
|
||||
const favorite = isFavorite(model.name);
|
||||
const hasApiKey = provider.hasApiKey;
|
||||
|
||||
// Build description lines for tooltip
|
||||
const priceLines = formatPricingLines(model.pricing || undefined);
|
||||
const descriptionLines = [
|
||||
model.maxInputTokens &&
|
||||
`Max tokens: ${model.maxInputTokens.toLocaleString()}`,
|
||||
Array.isArray(model.supportedFileTypes) &&
|
||||
model.supportedFileTypes.length > 0 &&
|
||||
`Supports: ${model.supportedFileTypes.join(', ')}`,
|
||||
model.default && 'Default model',
|
||||
!hasApiKey && 'API key required',
|
||||
...priceLines,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<Tooltip key={model.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => onUse(providerId, model)}
|
||||
onKeyDown={(e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && target.closest('button')) return;
|
||||
|
||||
const isEnter = e.key === 'Enter';
|
||||
const isSpace =
|
||||
e.key === ' ' ||
|
||||
e.key === 'Spacebar' ||
|
||||
e.code === 'Space';
|
||||
|
||||
if (isSpace) e.preventDefault();
|
||||
if (isEnter || isSpace) {
|
||||
onUse(providerId, model);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group/card relative flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-150 cursor-pointer outline-none',
|
||||
'hover:bg-accent/50 hover:border-accent-foreground/20 hover:shadow-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50',
|
||||
isActive
|
||||
? 'bg-primary/5 border-primary/30 shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-border/40 bg-card/30',
|
||||
!hasApiKey && 'opacity-60'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Model Name */}
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capability Icons */}
|
||||
<CapabilityIcons
|
||||
supportedFileTypes={model.supportedFileTypes}
|
||||
hasApiKey={hasApiKey}
|
||||
/>
|
||||
|
||||
{/* Favorite Star */}
|
||||
<button
|
||||
onKeyDown={(e) => {
|
||||
const isSpace =
|
||||
e.key === ' ' ||
|
||||
e.key === 'Spacebar' ||
|
||||
e.code === 'Space';
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (isEnter || isSpace) {
|
||||
e.stopPropagation();
|
||||
if (isSpace) e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(providerId, model.name);
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 p-1.5 rounded-lg transition-all duration-200',
|
||||
'hover:bg-accent hover:scale-110 active:scale-95',
|
||||
'opacity-0 group-hover/card:opacity-100',
|
||||
favorite && 'opacity-100'
|
||||
)}
|
||||
aria-label={
|
||||
favorite
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-4 w-4 transition-colors',
|
||||
favorite
|
||||
? 'fill-yellow-500 text-yellow-500'
|
||||
: 'text-muted-foreground hover:text-yellow-500'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Active Indicator */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-y-2 left-0 w-1 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-0.5">
|
||||
{descriptionLines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
57
dexto/packages/webui/components/ModelPicker/SearchBar.tsx
Normal file
57
dexto/packages/webui/components/ModelPicker/SearchBar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search models...',
|
||||
autoFocus = true,
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
'w-full h-11 pl-10 pr-10 rounded-xl',
|
||||
'bg-muted/50 border border-border/50',
|
||||
'text-sm placeholder:text-muted-foreground/70',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-accent transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
dexto/packages/webui/components/ModelPicker/constants.tsx
Normal file
107
dexto/packages/webui/components/ModelPicker/constants.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, FlaskConical, Zap } from 'lucide-react';
|
||||
import type { LLMProvider } from '@dexto/core';
|
||||
|
||||
// Provider logo file mapping - single source of truth
|
||||
// Empty string means "use Bot icon fallback" in components
|
||||
export const PROVIDER_LOGOS: Record<LLMProvider, string> = {
|
||||
openai: '/logos/openai.svg',
|
||||
anthropic: '/logos/claude-color.svg',
|
||||
google: '/logos/gemini-color.svg',
|
||||
groq: '/logos/groq.svg',
|
||||
xai: '/logos/grok.svg',
|
||||
'openai-compatible': '/logos/openai.svg',
|
||||
cohere: '/logos/cohere-color.svg',
|
||||
openrouter: '/logos/openrouter.svg',
|
||||
litellm: '/logos/litellm.svg',
|
||||
glama: '/logos/glama.svg',
|
||||
vertex: '/logos/gemini-color.svg', // Vertex AI uses Gemini logo (primary model family)
|
||||
bedrock: '/logos/aws-color.svg',
|
||||
local: '', // Uses Bot icon fallback - local GGUF models via node-llama-cpp
|
||||
ollama: '/logos/ollama.svg', // Ollama server
|
||||
dexto: '/logos/dexto/dexto_logo_icon.svg', // Dexto gateway - use Dexto logo
|
||||
};
|
||||
|
||||
// Provider pricing URLs (for quick access from Model Picker)
|
||||
export const PROVIDER_PRICING_URLS: Partial<Record<LLMProvider, string>> = {
|
||||
openai: 'https://platform.openai.com/docs/pricing',
|
||||
anthropic: 'https://www.anthropic.com/pricing#api',
|
||||
google: 'https://ai.google.dev/gemini-api/docs/pricing',
|
||||
groq: 'https://groq.com/pricing/',
|
||||
xai: 'https://docs.x.ai/docs/models',
|
||||
cohere: 'https://cohere.com/pricing',
|
||||
openrouter: 'https://openrouter.ai/models',
|
||||
litellm: 'https://docs.litellm.ai/',
|
||||
glama: 'https://glama.ai/',
|
||||
vertex: 'https://cloud.google.com/vertex-ai/generative-ai/pricing',
|
||||
bedrock: 'https://aws.amazon.com/bedrock/pricing/',
|
||||
// TODO: make this a valid URL
|
||||
dexto: 'https://dexto.ai/pricing',
|
||||
// 'openai-compatible' intentionally omitted (varies by vendor)
|
||||
};
|
||||
|
||||
// Helper: Format pricing from per‑million to per‑thousand tokens
|
||||
export function formatPricingLines(pricing?: {
|
||||
inputPerM?: number;
|
||||
outputPerM?: number;
|
||||
cacheReadPerM?: number;
|
||||
cacheWritePerM?: number;
|
||||
currency?: 'USD';
|
||||
unit?: 'per_million_tokens';
|
||||
}): string[] {
|
||||
if (!pricing) return [];
|
||||
// Bail early if required pricing fields are missing
|
||||
if (pricing.inputPerM == null || pricing.outputPerM == null) return [];
|
||||
const currency = pricing.currency || 'USD';
|
||||
const cur = currency === 'USD' ? '$' : '';
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`Cost: ${cur}${pricing.inputPerM.toFixed(2)} in / ${cur}${pricing.outputPerM.toFixed(2)} out per 1M tokens`
|
||||
);
|
||||
if (pricing.cacheReadPerM != null) {
|
||||
lines.push(`Cache read: ${cur}${pricing.cacheReadPerM.toFixed(2)} per 1M tokens`);
|
||||
}
|
||||
if (pricing.cacheWritePerM != null) {
|
||||
lines.push(`Cache write: ${cur}${pricing.cacheWritePerM.toFixed(2)} per 1M tokens`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Logos that have hardcoded colors and don't need dark mode inversion
|
||||
export const COLORED_LOGOS: readonly LLMProvider[] = [
|
||||
'google',
|
||||
'cohere',
|
||||
'anthropic',
|
||||
'vertex',
|
||||
'dexto',
|
||||
] as const;
|
||||
|
||||
// Helper to check if a logo needs dark mode inversion
|
||||
export const needsDarkModeInversion = (provider: LLMProvider): boolean => {
|
||||
return !COLORED_LOGOS.includes(provider);
|
||||
};
|
||||
|
||||
// Helper to check if a provider has a logo
|
||||
export const hasLogo = (provider: LLMProvider): boolean => {
|
||||
return !!PROVIDER_LOGOS[provider];
|
||||
};
|
||||
|
||||
// Model capability icons - sleek emojis for current capabilities
|
||||
export const CAPABILITY_ICONS = {
|
||||
// File type capabilities (what we currently use)
|
||||
image: <span className="text-sm">🖼️</span>,
|
||||
audio: <span className="text-sm">🎵</span>,
|
||||
pdf: <span className="text-sm">📄</span>,
|
||||
|
||||
// Other capabilities we currently have
|
||||
reasoning: <span className="text-sm">🧠</span>,
|
||||
experimental: (
|
||||
<FlaskConical className="h-3.5 w-3.5 text-muted-foreground hover:text-amber-500 transition-colors cursor-help" />
|
||||
),
|
||||
new: (
|
||||
<Sparkles className="h-3.5 w-3.5 text-muted-foreground hover:text-yellow-500 transition-colors cursor-help" />
|
||||
),
|
||||
realtime: (
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground hover:text-blue-500 transition-colors cursor-help" />
|
||||
),
|
||||
};
|
||||
1
dexto/packages/webui/components/ModelPicker/index.ts
Normal file
1
dexto/packages/webui/components/ModelPicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ModelPickerModal';
|
||||
80
dexto/packages/webui/components/ModelPicker/types.ts
Normal file
80
dexto/packages/webui/components/ModelPicker/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { SupportedFileType, LLMProvider } from '@dexto/core';
|
||||
|
||||
export type ModelInfo = {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
default?: boolean;
|
||||
maxInputTokens: number;
|
||||
supportedFileTypes: SupportedFileType[];
|
||||
pricing?: {
|
||||
inputPerM: number;
|
||||
outputPerM: number;
|
||||
cacheReadPerM?: number;
|
||||
cacheWritePerM?: number;
|
||||
currency?: 'USD';
|
||||
unit?: 'per_million_tokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type ProviderCatalog = {
|
||||
name: string;
|
||||
hasApiKey: boolean;
|
||||
primaryEnvVar: string;
|
||||
supportsBaseURL: boolean;
|
||||
models: ModelInfo[];
|
||||
};
|
||||
|
||||
export type CatalogResponse = { providers: Record<LLMProvider, ProviderCatalog> };
|
||||
|
||||
export type CurrentLLMConfigResponse = {
|
||||
config: {
|
||||
provider: string;
|
||||
model: string;
|
||||
displayName?: string;
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
maxInputTokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function favKey(provider: string, model: string) {
|
||||
return `${provider}|${model}`;
|
||||
}
|
||||
|
||||
export function validateBaseURL(url: string): { isValid: boolean; error?: string } {
|
||||
const str = url.trim();
|
||||
if (!str.length) return { isValid: true };
|
||||
try {
|
||||
const u = new URL(str);
|
||||
if (!['http:', 'https:'].includes(u.protocol)) {
|
||||
return { isValid: false, error: 'URL must use http:// or https://' };
|
||||
}
|
||||
if (!u.pathname.includes('/v1')) {
|
||||
return { isValid: false, error: 'URL must include /v1 for compatibility' };
|
||||
}
|
||||
return { isValid: true };
|
||||
} catch {
|
||||
return { isValid: false, error: 'Invalid URL format' };
|
||||
}
|
||||
}
|
||||
|
||||
export const FAVORITES_STORAGE_KEY = 'dexto:modelFavorites';
|
||||
export const CUSTOM_MODELS_STORAGE_KEY = 'dexto:customModels';
|
||||
|
||||
// Default favorites for new users (newest/best models)
|
||||
export const DEFAULT_FAVORITES = [
|
||||
'anthropic|claude-sonnet-4-5-20250929',
|
||||
'anthropic|claude-opus-4-5-20251101',
|
||||
'openai|gpt-5.1-chat-latest',
|
||||
'openai|gpt-5.1',
|
||||
'google|gemini-3-pro-preview',
|
||||
'google|gemini-3-pro-image-preview',
|
||||
];
|
||||
|
||||
// Minimal storage for custom models - other fields are inferred
|
||||
export interface CustomModelStorage {
|
||||
name: string; // Model identifier
|
||||
baseURL: string; // OpenAI-compatible endpoint
|
||||
maxInputTokens?: number; // Optional, defaults to 128k
|
||||
maxOutputTokens?: number; // Optional, provider decides
|
||||
}
|
||||
34
dexto/packages/webui/components/NewChatButton.tsx
Normal file
34
dexto/packages/webui/components/NewChatButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
variant?: 'outline' | 'ghost';
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
};
|
||||
|
||||
export function NewChatButton({ onClick, className, variant = 'outline', side = 'bottom' }: Props) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={variant}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={['h-8 w-8 p-0', className].filter(Boolean).join(' ')}
|
||||
aria-label="New chat"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>New Chat (⌘K)</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewChatButton;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, XCircle, History } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export interface ExecutionHistoryItem {
|
||||
id: string;
|
||||
toolName: string;
|
||||
timestamp: Date;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ExecutionHistoryProps {
|
||||
history: ExecutionHistoryItem[];
|
||||
}
|
||||
|
||||
export function ExecutionHistory({ history }: ExecutionHistoryProps) {
|
||||
if (history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const successCount = history.filter((h) => h.success).length;
|
||||
const failureCount = history.filter((h) => !h.success).length;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Execution History</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{history.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
<span>{successCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
<span>{failureCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{item.success ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{item.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.duration}ms
|
||||
</span>
|
||||
)}
|
||||
<span>{new Date(item.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
570
dexto/packages/webui/components/Playground/PlaygroundView.tsx
Normal file
570
dexto/packages/webui/components/Playground/PlaygroundView.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft, AlertTriangle, CheckCircle, PanelLeftClose, PanelLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import ConnectServerModal from '../ConnectServerModal';
|
||||
import { ServersList } from './ServersList';
|
||||
import { ToolsList } from './ToolsList';
|
||||
import { ToolInputForm } from './ToolInputForm';
|
||||
import { ToolResult } from './ToolResult';
|
||||
import { ExecutionHistory, type ExecutionHistoryItem } from './ExecutionHistory';
|
||||
import type { ToolResult as ToolResultType } from '@dexto/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { client } from '@/lib/client';
|
||||
import { useServers, useServerTools } from '../hooks/useServers';
|
||||
import type { McpServer, McpTool } from '../hooks/useServers';
|
||||
|
||||
export default function PlaygroundView() {
|
||||
const [selectedServer, setSelectedServer] = useState<McpServer | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<McpTool | null>(null);
|
||||
const [toolInputs, setToolInputs] = useState<Record<string, any>>({});
|
||||
const [toolResult, setToolResult] = useState<ToolResultType | null>(null);
|
||||
const [currentError, setCurrentError] = useState<string | null>(null);
|
||||
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
|
||||
const [isConnectModalOpen, setIsConnectModalOpen] = useState(false);
|
||||
const [executionLoading, setExecutionLoading] = useState(false);
|
||||
const [executionHistory, setExecutionHistory] = useState<ExecutionHistoryItem[]>([]);
|
||||
const [clipboardNotification, setClipboardNotification] = useState<{
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
} | null>(null);
|
||||
|
||||
// Search states
|
||||
const [serverSearchQuery, setServerSearchQuery] = useState('');
|
||||
const [toolSearchQuery, setToolSearchQuery] = useState('');
|
||||
|
||||
// Responsive sidebar states
|
||||
const [showServersSidebar, setShowServersSidebar] = useState(true);
|
||||
const [showToolsSidebar, setShowToolsSidebar] = useState(true);
|
||||
|
||||
const executionAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading: serversLoading,
|
||||
error: serversError,
|
||||
refetch: refetchServers,
|
||||
} = useServers();
|
||||
|
||||
const {
|
||||
data: tools = [],
|
||||
isLoading: toolsLoading,
|
||||
error: toolsError,
|
||||
} = useServerTools(
|
||||
selectedServer?.id || null,
|
||||
!!selectedServer && selectedServer.status === 'connected'
|
||||
);
|
||||
|
||||
const handleError = (message: string, area?: 'servers' | 'tools' | 'execution' | 'input') => {
|
||||
console.error(`Playground Error (${area || 'general'}):`, message);
|
||||
if (area !== 'input') {
|
||||
setCurrentError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerSelect = useCallback((server: McpServer) => {
|
||||
setSelectedServer(server);
|
||||
setSelectedTool(null);
|
||||
setToolResult(null);
|
||||
setCurrentError(null);
|
||||
setInputErrors({});
|
||||
}, []);
|
||||
|
||||
const handleToolSelect = useCallback((tool: McpTool) => {
|
||||
setSelectedTool(tool);
|
||||
setToolResult(null);
|
||||
setCurrentError(null);
|
||||
setInputErrors({});
|
||||
const defaultInputs: Record<string, any> = {};
|
||||
if (tool.inputSchema && tool.inputSchema.properties) {
|
||||
for (const key in tool.inputSchema.properties) {
|
||||
const prop = tool.inputSchema.properties[key];
|
||||
if (prop.default !== undefined) {
|
||||
defaultInputs[key] = prop.default;
|
||||
} else {
|
||||
if (prop.type === 'boolean') defaultInputs[key] = false;
|
||||
else if (prop.type === 'number' || prop.type === 'integer')
|
||||
defaultInputs[key] = '';
|
||||
else if (prop.type === 'object' || prop.type === 'array')
|
||||
defaultInputs[key] = '';
|
||||
else defaultInputs[key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
setToolInputs(defaultInputs);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(
|
||||
inputName: string,
|
||||
value: any,
|
||||
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
|
||||
) => {
|
||||
setToolInputs((prev) => ({ ...prev, [inputName]: value }));
|
||||
if (inputErrors[inputName]) {
|
||||
setInputErrors((prev) => ({ ...prev, [inputName]: '' }));
|
||||
}
|
||||
|
||||
if (type === 'object' || type === 'array') {
|
||||
if (value === '') return;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
setInputErrors((prev) => ({ ...prev, [inputName]: 'Invalid JSON format' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[inputErrors]
|
||||
);
|
||||
|
||||
const validateInputs = useCallback((): boolean => {
|
||||
if (!selectedTool || !selectedTool.inputSchema || !selectedTool.inputSchema.properties) {
|
||||
return true;
|
||||
}
|
||||
const currentInputErrors: Record<string, string> = {};
|
||||
let allValid = true;
|
||||
|
||||
for (const key in selectedTool.inputSchema.properties) {
|
||||
const prop = selectedTool.inputSchema.properties[key];
|
||||
const value = toolInputs[key];
|
||||
|
||||
if (selectedTool.inputSchema.required?.includes(key)) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === '' ||
|
||||
(prop.type === 'boolean' && typeof value !== 'boolean')
|
||||
) {
|
||||
currentInputErrors[key] = 'This field is required.';
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(prop.type === 'number' || prop.type === 'integer') &&
|
||||
value !== '' &&
|
||||
isNaN(Number(value))
|
||||
) {
|
||||
currentInputErrors[key] = 'Must be a valid number.';
|
||||
allValid = false;
|
||||
}
|
||||
|
||||
if ((prop.type === 'object' || prop.type === 'array') && value !== '') {
|
||||
try {
|
||||
JSON.parse(value as string);
|
||||
} catch {
|
||||
currentInputErrors[key] = 'Invalid JSON format.';
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
setInputErrors(currentInputErrors);
|
||||
return allValid;
|
||||
}, [selectedTool, toolInputs]);
|
||||
|
||||
const handleExecuteTool = useCallback(async () => {
|
||||
if (!selectedServer || !selectedTool) {
|
||||
handleError('No server or tool selected for execution.', 'execution');
|
||||
return;
|
||||
}
|
||||
executionAbortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
executionAbortControllerRef.current = controller;
|
||||
setCurrentError(null);
|
||||
setToolResult(null);
|
||||
|
||||
if (!validateInputs()) {
|
||||
handleError('Please correct the input errors.', 'input');
|
||||
return;
|
||||
}
|
||||
|
||||
const executionStart = Date.now();
|
||||
const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
setExecutionLoading(true);
|
||||
try {
|
||||
const processedInputs: Record<string, any> = {};
|
||||
if (selectedTool.inputSchema && selectedTool.inputSchema.properties) {
|
||||
for (const key in selectedTool.inputSchema.properties) {
|
||||
const prop = selectedTool.inputSchema.properties[key];
|
||||
let value = toolInputs[key];
|
||||
if (prop.type === 'number') {
|
||||
value = value === '' ? undefined : Number(value);
|
||||
} else if (prop.type === 'integer') {
|
||||
if (value === '') {
|
||||
value = undefined;
|
||||
} else {
|
||||
const num = Number(value);
|
||||
if (!Number.isInteger(num)) {
|
||||
setInputErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: 'Must be a valid integer.',
|
||||
}));
|
||||
setExecutionLoading(false);
|
||||
return;
|
||||
}
|
||||
value = num;
|
||||
}
|
||||
} else if (prop.type === 'boolean') {
|
||||
if (typeof value === 'string') {
|
||||
value = value === 'true';
|
||||
} else {
|
||||
value = Boolean(value);
|
||||
}
|
||||
} else if (
|
||||
(prop.type === 'object' || prop.type === 'array') &&
|
||||
typeof value === 'string' &&
|
||||
value.trim() !== ''
|
||||
) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
setInputErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: 'Invalid JSON before sending.',
|
||||
}));
|
||||
setExecutionLoading(false);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
(prop.type === 'object' || prop.type === 'array') &&
|
||||
(value === undefined || value === '')
|
||||
) {
|
||||
value = undefined;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
processedInputs[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await client.api.mcp.servers[':serverId'].tools[
|
||||
':toolName'
|
||||
].execute.$post(
|
||||
{
|
||||
param: {
|
||||
serverId: selectedServer.id,
|
||||
toolName: selectedTool.id,
|
||||
},
|
||||
json: processedInputs,
|
||||
},
|
||||
{ init: { signal: controller.signal } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Tool execution failed');
|
||||
}
|
||||
|
||||
const resultData = await response.json();
|
||||
|
||||
const duration = Date.now() - executionStart;
|
||||
setToolResult(resultData);
|
||||
|
||||
setExecutionHistory((prev) => [
|
||||
{
|
||||
id: executionId,
|
||||
toolName: selectedTool.name,
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
duration,
|
||||
},
|
||||
...prev.slice(0, 9),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
const duration = Date.now() - executionStart;
|
||||
handleError(err.message, 'execution');
|
||||
if (
|
||||
err.message &&
|
||||
(!toolResult || toolResult.success || toolResult.error !== err.message)
|
||||
) {
|
||||
setToolResult({ success: false, error: err.message });
|
||||
}
|
||||
|
||||
setExecutionHistory((prev) => [
|
||||
{
|
||||
id: executionId,
|
||||
toolName: selectedTool?.name || 'Unknown',
|
||||
timestamp: new Date(),
|
||||
success: false,
|
||||
duration,
|
||||
},
|
||||
...prev.slice(0, 9),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setExecutionLoading(false);
|
||||
}
|
||||
}
|
||||
}, [selectedServer, selectedTool, toolInputs, validateInputs, toolResult]);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsConnectModalOpen(false);
|
||||
refetchServers();
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, successMessage?: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setClipboardNotification({
|
||||
message: successMessage || 'Copied to clipboard',
|
||||
type: 'success',
|
||||
});
|
||||
setTimeout(() => setClipboardNotification(null), 3000);
|
||||
} catch (err) {
|
||||
setClipboardNotification({
|
||||
message: 'Failed to copy to clipboard. Please check browser permissions.',
|
||||
type: 'error',
|
||||
});
|
||||
setTimeout(() => setClipboardNotification(null), 5000);
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToolConfiguration = () => {
|
||||
if (!selectedTool || !selectedServer) return;
|
||||
const config = {
|
||||
server: selectedServer.name,
|
||||
tool: selectedTool.name,
|
||||
inputs: toolInputs,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
copyToClipboard(JSON.stringify(config, null, 2), 'Tool configuration copied!');
|
||||
};
|
||||
|
||||
const copyToolResult = () => {
|
||||
if (!toolResult) return;
|
||||
const resultText =
|
||||
typeof toolResult.data === 'object'
|
||||
? JSON.stringify(toolResult.data, null, 2)
|
||||
: String(toolResult.data);
|
||||
copyToClipboard(resultText, 'Tool result copied!');
|
||||
};
|
||||
|
||||
const shareToolConfig = () => {
|
||||
if (!selectedTool || !selectedServer) return;
|
||||
const shareText = `Check out this Dexto tool configuration:\n\nServer: ${selectedServer.name}\nTool: ${selectedTool.name}\nInputs: ${JSON.stringify(toolInputs, null, 2)}`;
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: `Dexto Tool: ${selectedTool.name}`,
|
||||
text: shareText,
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(shareText, 'Tool configuration copied for sharing!');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground antialiased">
|
||||
{/* Servers Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'w-72 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
|
||||
'lg:relative lg:translate-x-0',
|
||||
showServersSidebar
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
|
||||
)}
|
||||
>
|
||||
{showServersSidebar && (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-border">
|
||||
<Link to="/">
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowServersSidebar(false)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ServersList
|
||||
servers={servers}
|
||||
selectedServer={selectedServer}
|
||||
isLoading={serversLoading}
|
||||
error={serversError?.message || currentError}
|
||||
searchQuery={serverSearchQuery}
|
||||
onSearchChange={setServerSearchQuery}
|
||||
onServerSelect={handleServerSelect}
|
||||
onConnectNew={() => setIsConnectModalOpen(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Tools Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'w-80 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
|
||||
'lg:relative lg:translate-x-0',
|
||||
showToolsSidebar
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
|
||||
)}
|
||||
>
|
||||
{showToolsSidebar && (
|
||||
<ToolsList
|
||||
tools={tools}
|
||||
selectedTool={selectedTool}
|
||||
selectedServer={selectedServer}
|
||||
isLoading={toolsLoading}
|
||||
error={
|
||||
toolsError?.message ||
|
||||
(selectedServer?.status === 'connected' ? currentError : null)
|
||||
}
|
||||
searchQuery={toolSearchQuery}
|
||||
onSearchChange={setToolSearchQuery}
|
||||
onToolSelect={handleToolSelect}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6 flex flex-col bg-muted/30 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="pb-3 mb-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{!showServersSidebar && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServersSidebar(true)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!showToolsSidebar && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowToolsSidebar(true)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-foreground">Tool Runner</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clipboard Notification */}
|
||||
{clipboardNotification && (
|
||||
<Alert
|
||||
variant={clipboardNotification.type === 'error' ? 'destructive' : 'default'}
|
||||
className={cn(
|
||||
'mb-4',
|
||||
clipboardNotification.type === 'success' &&
|
||||
'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-400'
|
||||
)}
|
||||
>
|
||||
{clipboardNotification.type === 'error' && (
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
)}
|
||||
{clipboardNotification.type === 'success' && (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>{clipboardNotification.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{currentError && selectedTool && (!toolResult || !toolResult.success) && (
|
||||
<div className="mb-4 p-3 border border-destructive/50 bg-destructive/10 rounded-md text-destructive text-sm">
|
||||
<p className="font-medium">Error:</p>
|
||||
<p>{currentError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!selectedTool && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mb-4">
|
||||
<ArrowLeft className="h-12 w-12 mx-auto text-muted-foreground opacity-50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Select a Tool</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Choose a tool from the left panel to start testing and experimenting
|
||||
with MCP capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Content */}
|
||||
{selectedTool && (
|
||||
<div className="space-y-6">
|
||||
{/* Tool Info Card */}
|
||||
<div className="p-4 border border-border rounded-lg bg-card shadow-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-primary mb-1">
|
||||
{selectedTool.name}
|
||||
</h3>
|
||||
{selectedTool.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>Server: {selectedServer?.name}</p>
|
||||
{executionHistory.filter(
|
||||
(h) => h.toolName === selectedTool.name
|
||||
).length > 0 && (
|
||||
<p>
|
||||
Runs:{' '}
|
||||
{
|
||||
executionHistory.filter(
|
||||
(h) => h.toolName === selectedTool.name
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Input Form */}
|
||||
<ToolInputForm
|
||||
tool={selectedTool}
|
||||
inputs={toolInputs}
|
||||
errors={inputErrors}
|
||||
isLoading={executionLoading}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleExecuteTool}
|
||||
onCopyConfig={copyToolConfiguration}
|
||||
onShareConfig={shareToolConfig}
|
||||
/>
|
||||
|
||||
{/* Tool Result */}
|
||||
{toolResult && (
|
||||
<ToolResult
|
||||
result={toolResult}
|
||||
toolName={selectedTool.name}
|
||||
onCopyResult={copyToolResult}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Execution History */}
|
||||
<ExecutionHistory history={executionHistory} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ConnectServerModal isOpen={isConnectModalOpen} onClose={handleModalClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
dexto/packages/webui/components/Playground/ServersList.tsx
Normal file
174
dexto/packages/webui/components/Playground/ServersList.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { Server, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { McpServer } from '@/components/hooks/useServers';
|
||||
|
||||
interface ServersListProps {
|
||||
servers: McpServer[];
|
||||
selectedServer: McpServer | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onServerSelect: (server: McpServer) => void;
|
||||
onConnectNew: () => void;
|
||||
}
|
||||
|
||||
export function ServersList({
|
||||
servers,
|
||||
selectedServer,
|
||||
isLoading,
|
||||
error,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onServerSelect,
|
||||
onConnectNew,
|
||||
}: ServersListProps) {
|
||||
const filteredServers = servers.filter((server) =>
|
||||
server.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusIcon = (status: McpServer['status']) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <Check className="h-3 w-3" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'disconnected':
|
||||
default:
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: McpServer['status']) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-700/20 dark:text-green-400';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-700/20 dark:text-red-400';
|
||||
case 'disconnected':
|
||||
return 'bg-slate-100 text-slate-600 dark:bg-slate-700/20 dark:text-slate-400';
|
||||
default:
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/20 dark:text-yellow-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="pb-3 mb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">MCP Servers</h2>
|
||||
{isLoading && servers.length === 0 && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Search servers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && servers.length === 0 && !isLoading && (
|
||||
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
|
||||
<p className="font-medium">Error loading servers</p>
|
||||
<p className="text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && servers.length === 0 && (
|
||||
<div className="flex-1 space-y-2 pr-1">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="p-2.5 rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{servers.length === 0 && !isLoading && !error && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<Server className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No servers available</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Connect a server to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Servers List */}
|
||||
{filteredServers.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
|
||||
{filteredServers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => server.status === 'connected' && onServerSelect(server)}
|
||||
disabled={server.status !== 'connected'}
|
||||
className={cn(
|
||||
'w-full p-2.5 rounded-lg text-left transition-all duration-200',
|
||||
'hover:shadow-sm border border-transparent',
|
||||
selectedServer?.id === server.id
|
||||
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
|
||||
: 'hover:bg-muted hover:border-border',
|
||||
server.status !== 'connected' && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
title={
|
||||
server.status !== 'connected'
|
||||
? `${server.name} is ${server.status}`
|
||||
: server.name
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0 h-5 flex items-center gap-1',
|
||||
getStatusColor(server.status)
|
||||
)}
|
||||
>
|
||||
{getStatusIcon(server.status)}
|
||||
{server.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{filteredServers.length === 0 && servers.length > 0 && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-sm text-muted-foreground">No servers match your search</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect Button */}
|
||||
<Button
|
||||
onClick={onConnectNew}
|
||||
variant="outline"
|
||||
className="mt-auto w-full sticky bottom-0 bg-background"
|
||||
size="sm"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
Connect New Server
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
341
dexto/packages/webui/components/Playground/ToolInputForm.tsx
Normal file
341
dexto/packages/webui/components/Playground/ToolInputForm.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Copy, Share2, Zap } from 'lucide-react';
|
||||
import type { McpTool } from '@/components/hooks/useServers';
|
||||
|
||||
// Infer the property schema type from the tool's input schema
|
||||
type JsonSchemaProperty = NonNullable<NonNullable<McpTool['inputSchema']>['properties']>[string];
|
||||
|
||||
interface ToolInputFormProps {
|
||||
tool: McpTool;
|
||||
inputs: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
onInputChange: (
|
||||
name: string,
|
||||
value: any,
|
||||
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
|
||||
) => void;
|
||||
onSubmit: () => void;
|
||||
onCopyConfig?: () => void;
|
||||
onShareConfig?: () => void;
|
||||
}
|
||||
|
||||
interface ToolTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
apply: (tool: McpTool) => Record<string, any>;
|
||||
}
|
||||
|
||||
const toolTemplates: ToolTemplate[] = [
|
||||
{
|
||||
name: 'Quick Test',
|
||||
description: 'Fill with test values',
|
||||
apply: (tool: McpTool) => {
|
||||
const defaults: Record<string, any> = {};
|
||||
if (tool.inputSchema?.properties) {
|
||||
Object.entries(tool.inputSchema.properties).forEach(
|
||||
([key, prop]: [string, any]) => {
|
||||
if (prop.type === 'string') defaults[key] = `test-${key}`;
|
||||
else if (prop.type === 'number') defaults[key] = 42;
|
||||
else if (prop.type === 'boolean') defaults[key] = true;
|
||||
else if (prop.type === 'object') defaults[key] = '{"example": "value"}';
|
||||
else if (prop.type === 'array') defaults[key] = '["example"]';
|
||||
}
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Required Only',
|
||||
description: 'Fill only required fields',
|
||||
apply: (tool: McpTool) => {
|
||||
const defaults: Record<string, any> = {};
|
||||
if (tool.inputSchema?.properties && tool.inputSchema?.required) {
|
||||
tool.inputSchema.required.forEach((key: string) => {
|
||||
const prop = tool.inputSchema!.properties![key];
|
||||
if (prop.type === 'string') defaults[key] = '';
|
||||
else if (prop.type === 'number') defaults[key] = '';
|
||||
else if (prop.type === 'boolean') defaults[key] = false;
|
||||
else if (prop.type === 'object') defaults[key] = '{}';
|
||||
else if (prop.type === 'array') defaults[key] = '[]';
|
||||
});
|
||||
}
|
||||
return defaults;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clear All',
|
||||
description: 'Clear all fields',
|
||||
apply: () => ({}),
|
||||
},
|
||||
];
|
||||
|
||||
export function ToolInputForm({
|
||||
tool,
|
||||
inputs,
|
||||
errors,
|
||||
isLoading,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onCopyConfig,
|
||||
onShareConfig,
|
||||
}: ToolInputFormProps) {
|
||||
const hasInputs =
|
||||
tool.inputSchema?.properties && Object.keys(tool.inputSchema.properties).length > 0;
|
||||
|
||||
const renderInput = (key: string, prop: JsonSchemaProperty) => {
|
||||
const isRequired = tool.inputSchema?.required?.includes(key);
|
||||
const errorMsg = errors[key];
|
||||
const baseInputClassName = `w-full ${errorMsg ? 'border-destructive focus-visible:ring-destructive' : ''}`;
|
||||
|
||||
// Enum select
|
||||
if (prop.enum && Array.isArray(prop.enum)) {
|
||||
const isEnumBoolean = prop.enum.every(
|
||||
(v: string | number | boolean) => typeof v === 'boolean'
|
||||
);
|
||||
const isEnumNumeric = prop.enum.every(
|
||||
(v: string | number | boolean) => typeof v === 'number'
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
value={
|
||||
inputs[key] === undefined && prop.default !== undefined
|
||||
? String(prop.default)
|
||||
: String(inputs[key] || '')
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
let parsedValue: string | number | boolean = value;
|
||||
if (isEnumBoolean) parsedValue = value === 'true';
|
||||
else if (isEnumNumeric) parsedValue = Number(value);
|
||||
onInputChange(key, parsedValue, prop.type);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger id={key} className={baseInputClassName}>
|
||||
<SelectValue
|
||||
placeholder={`Select ${prop.description || key}${isRequired ? '' : ' (optional)'}...`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{prop.enum.map((enumValue: string | number | boolean) => (
|
||||
<SelectItem key={String(enumValue)} value={String(enumValue)}>
|
||||
{String(enumValue)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean checkbox
|
||||
if (prop.type === 'boolean') {
|
||||
return (
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={
|
||||
inputs[key] === undefined && prop.default !== undefined
|
||||
? Boolean(prop.default)
|
||||
: Boolean(inputs[key])
|
||||
}
|
||||
onCheckedChange={(checked) => onInputChange(key, checked, prop.type)}
|
||||
disabled={isLoading}
|
||||
className={errorMsg ? 'border-destructive ring-destructive' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Object/Array textarea
|
||||
if (prop.type === 'object' || prop.type === 'array') {
|
||||
return (
|
||||
<Textarea
|
||||
id={key}
|
||||
value={
|
||||
inputs[key] === undefined && prop.default !== undefined
|
||||
? JSON.stringify(prop.default, null, 2)
|
||||
: inputs[key] || ''
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onInputChange(key, e.target.value, prop.type)
|
||||
}
|
||||
rows={5}
|
||||
className={`${baseInputClassName} font-mono text-sm min-h-[100px] resize-y`}
|
||||
placeholder={`Enter JSON for ${prop.description || key}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// String/Number input
|
||||
let inputFieldType: React.HTMLInputTypeAttribute = 'text';
|
||||
if (prop.type === 'number' || prop.type === 'integer') inputFieldType = 'number';
|
||||
if (prop.format === 'date-time') inputFieldType = 'datetime-local';
|
||||
if (prop.format === 'date') inputFieldType = 'date';
|
||||
if (prop.format === 'email') inputFieldType = 'email';
|
||||
if (prop.format === 'uri') inputFieldType = 'url';
|
||||
if (prop.format === 'password') inputFieldType = 'password';
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={inputFieldType}
|
||||
id={key}
|
||||
value={
|
||||
inputs[key] === undefined && prop.default !== undefined
|
||||
? String(prop.default)
|
||||
: String(inputs[key] || '')
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onInputChange(key, e.target.value, prop.type)
|
||||
}
|
||||
className={baseInputClassName}
|
||||
placeholder={prop.description || `Enter ${key}`}
|
||||
disabled={isLoading}
|
||||
step={prop.type === 'number' || prop.type === 'integer' ? 'any' : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="space-y-5 p-4 border border-border rounded-lg bg-card shadow-sm"
|
||||
>
|
||||
{/* Quick Fill Templates */}
|
||||
{hasInputs && (
|
||||
<div className="border-b border-border pb-4">
|
||||
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Quick Fill</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toolTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newInputs = template.apply(tool);
|
||||
Object.entries(newInputs).forEach(([key, value]) => {
|
||||
const prop = tool.inputSchema?.properties?.[key];
|
||||
onInputChange(key, value, prop?.type);
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
title={template.description}
|
||||
>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Inputs */}
|
||||
{!hasInputs && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
This tool does not require any inputs.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasInputs &&
|
||||
Object.entries(tool.inputSchema!.properties!).map(([key, prop]) => {
|
||||
const isRequired = tool.inputSchema?.required?.includes(key);
|
||||
const errorMsg = errors[key];
|
||||
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<div
|
||||
className={`flex ${
|
||||
prop.type === 'boolean'
|
||||
? 'flex-row items-center space-x-3'
|
||||
: 'flex-col'
|
||||
}`}
|
||||
>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className={`${
|
||||
prop.type === 'boolean'
|
||||
? 'leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
: 'capitalize font-medium'
|
||||
}`}
|
||||
>
|
||||
{prop.description ||
|
||||
key
|
||||
.replace(/([A-Z]+(?=[A-Z][a-z]))|([A-Z][a-z])/g, ' $&')
|
||||
.trim()
|
||||
.replace(/_/g, ' ')}
|
||||
{isRequired && (
|
||||
<span className="text-destructive text-lg ml-0.5">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.type === 'boolean' ? (
|
||||
renderInput(key, prop)
|
||||
) : (
|
||||
<div className="w-full">{renderInput(key, prop)}</div>
|
||||
)}
|
||||
</div>
|
||||
{errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || Object.keys(errors).some((k) => errors[k] !== '')}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
'Executing...'
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Run Tool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{hasInputs && Object.keys(inputs).length > 0 && (
|
||||
<>
|
||||
{onCopyConfig && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopyConfig}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
{onShareConfig && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShareConfig}
|
||||
>
|
||||
<Share2 className="h-3 w-3 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
123
dexto/packages/webui/components/Playground/ToolResult.tsx
Normal file
123
dexto/packages/webui/components/Playground/ToolResult.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle, Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ToolResult as ToolResultType } from '@dexto/core';
|
||||
|
||||
interface ToolResultProps {
|
||||
result: ToolResultType;
|
||||
toolName: string;
|
||||
onCopyResult?: () => void;
|
||||
}
|
||||
|
||||
export function ToolResult({ result, toolName, onCopyResult }: ToolResultProps) {
|
||||
const renderResultContent = () => {
|
||||
// Check if this is an image result by examining the data structure
|
||||
const isImageResult =
|
||||
result.data &&
|
||||
typeof result.data === 'object' &&
|
||||
(Array.isArray(result.data) ||
|
||||
(typeof result.data === 'object' && Array.isArray((result.data as any).content)));
|
||||
|
||||
if (isImageResult && result.data) {
|
||||
let imgSrc = '';
|
||||
let imagePart: { data?: string; mimeType?: string; type?: string } | null = null;
|
||||
let nonImageParts: any[] = [];
|
||||
|
||||
if (Array.isArray(result.data)) {
|
||||
imagePart = result.data.find((part) => part && part.type === 'image');
|
||||
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
|
||||
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
|
||||
}
|
||||
} else if (
|
||||
result.data &&
|
||||
typeof result.data === 'object' &&
|
||||
Array.isArray((result.data as any).content)
|
||||
) {
|
||||
const partsArray = (result.data as any).content as any[];
|
||||
imagePart = partsArray.find((part) => part && part.type === 'image');
|
||||
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
|
||||
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
|
||||
}
|
||||
nonImageParts = partsArray.filter((part) => part && part.type !== 'image');
|
||||
} else if (typeof result.data === 'string') {
|
||||
if (result.data.startsWith('data:image')) {
|
||||
imgSrc = result.data;
|
||||
} else if (
|
||||
result.data.startsWith('http://') ||
|
||||
result.data.startsWith('https://')
|
||||
) {
|
||||
imgSrc = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (imgSrc) {
|
||||
return (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="Tool result"
|
||||
className="my-2 max-h-96 w-auto rounded-lg border border-border shadow-sm"
|
||||
/>
|
||||
);
|
||||
} else if (nonImageParts.length > 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{nonImageParts.map((part, idx) => (
|
||||
<pre
|
||||
key={idx}
|
||||
className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border font-mono overflow-x-auto max-h-64"
|
||||
>
|
||||
{typeof part === 'object'
|
||||
? JSON.stringify(part, null, 2)
|
||||
: String(part)}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default result rendering
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border overflow-x-auto">
|
||||
{typeof result.data === 'object'
|
||||
? JSON.stringify(result.data, null, 2)
|
||||
: String(result.data)}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 p-4 border border-border rounded-lg bg-card shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3 pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{result.success ? 'Success' : 'Error'}
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">• {toolName}</span>
|
||||
</div>
|
||||
{onCopyResult && result.success && (
|
||||
<Button variant="outline" size="sm" onClick={onCopyResult}>
|
||||
<Copy className="h-3 w-3 mr-2" />
|
||||
Copy Result
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.success ? (
|
||||
<div className="space-y-3">{renderResultContent()}</div>
|
||||
) : (
|
||||
<div className="p-3 bg-destructive/10 rounded-md">
|
||||
<p className="text-sm text-destructive font-semibold">Error executing tool:</p>
|
||||
<pre className="mt-1 text-xs text-destructive whitespace-pre-wrap break-all">
|
||||
{result.error || 'Unknown error'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
dexto/packages/webui/components/Playground/ToolsList.tsx
Normal file
186
dexto/packages/webui/components/Playground/ToolsList.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Wrench, Search, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { McpServer, McpTool } from '@/components/hooks/useServers';
|
||||
|
||||
interface ToolsListProps {
|
||||
tools: McpTool[];
|
||||
selectedTool: McpTool | null;
|
||||
selectedServer: McpServer | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onToolSelect: (tool: McpTool) => void;
|
||||
}
|
||||
|
||||
export function ToolsList({
|
||||
tools,
|
||||
selectedTool,
|
||||
selectedServer,
|
||||
isLoading,
|
||||
error,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onToolSelect,
|
||||
}: ToolsListProps) {
|
||||
const filteredTools = tools.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="pb-3 mb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Tools</h2>
|
||||
{isLoading && tools.length === 0 && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
|
||||
)}
|
||||
{tools.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{filteredTools.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tools.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 text-sm pl-7"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Server Selected */}
|
||||
{!selectedServer && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Select a server</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose a connected server to view its tools
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Not Connected */}
|
||||
{selectedServer && selectedServer.status !== 'connected' && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Server not connected</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
"{selectedServer.name}" is {selectedServer.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && selectedServer?.status === 'connected' && !isLoading && (
|
||||
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
|
||||
<p className="font-medium">Error loading tools</p>
|
||||
<p className="text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && selectedServer?.status === 'connected' && tools.length === 0 && (
|
||||
<div className="flex-1 space-y-2 pr-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="p-3 rounded-lg border border-border">
|
||||
<div className="flex items-start gap-2">
|
||||
<Skeleton className="h-4 w-4 mt-0.5 flex-shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{selectedServer &&
|
||||
selectedServer.status === 'connected' &&
|
||||
!isLoading &&
|
||||
tools.length === 0 &&
|
||||
!error && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No tools available</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
No tools found for {selectedServer.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools List */}
|
||||
{filteredTools.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
|
||||
{filteredTools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => onToolSelect(tool)}
|
||||
className={cn(
|
||||
'w-full p-3 rounded-lg text-left transition-all duration-200',
|
||||
'hover:shadow-sm border border-transparent',
|
||||
selectedTool?.id === tool.id
|
||||
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
|
||||
: 'hover:bg-muted hover:border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
|
||||
{tool.description && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs mt-1 line-clamp-2',
|
||||
selectedTool?.id === tool.id
|
||||
? 'text-primary-foreground/80'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Search Results */}
|
||||
{filteredTools.length === 0 && tools.length > 0 && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<Search className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No tools match your search</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Try a different search term
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
dexto/packages/webui/components/QueuedMessagesDisplay.tsx
Normal file
91
dexto/packages/webui/components/QueuedMessagesDisplay.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import type { QueuedMessage } from './hooks/useQueue';
|
||||
import { isTextPart } from '../types';
|
||||
|
||||
interface QueuedMessagesDisplayProps {
|
||||
messages: QueuedMessage[];
|
||||
onEditMessage: (message: QueuedMessage) => void;
|
||||
onRemoveMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays queued messages with visual indicators and keyboard hints.
|
||||
*/
|
||||
export function QueuedMessagesDisplay({
|
||||
messages,
|
||||
onEditMessage,
|
||||
onRemoveMessage,
|
||||
}: QueuedMessagesDisplayProps) {
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
// Extract text content from message
|
||||
const getMessageText = (message: QueuedMessage): string => {
|
||||
const textParts = message.content.filter(isTextPart).map((part) => part.text);
|
||||
return textParts.join(' ') || '[attachment]';
|
||||
};
|
||||
|
||||
// Truncate text to max lines
|
||||
const truncateText = (text: string, maxLines: number = 2): string => {
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) {
|
||||
return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||
}
|
||||
return lines.slice(0, maxLines).join('\n') + '...';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1.5 px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{messages.length} message{messages.length !== 1 ? 's' : ''} queued
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<kbd className="px-1 py-0.5 bg-muted border border-border rounded text-[10px]">
|
||||
<ArrowUp className="h-2.5 w-2.5 inline" />
|
||||
</kbd>
|
||||
<span className="ml-1">to edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="space-y-1">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="group flex items-start gap-2 px-1 py-1 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* Arrow indicator */}
|
||||
<span className="text-muted-foreground text-xs mt-0.5 select-none">
|
||||
{index === messages.length - 1 ? '↳' : '│'}
|
||||
</span>
|
||||
|
||||
{/* Message content */}
|
||||
<button
|
||||
onClick={() => onEditMessage(message)}
|
||||
className="flex-1 text-left text-sm text-muted-foreground italic hover:text-foreground transition-colors truncate"
|
||||
title="Click to edit"
|
||||
>
|
||||
{truncateText(getMessageText(message))}
|
||||
</button>
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveMessage(message.id)}
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Remove from queue"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
dexto/packages/webui/components/ResourceAutocomplete.tsx
Normal file
179
dexto/packages/webui/components/ResourceAutocomplete.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import type { ResourceMetadata } from '@dexto/core';
|
||||
import { useResourceContent } from './hooks/useResourceContent';
|
||||
import type { NormalizedResourceItem, ResourceState } from './hooks/useResourceContent';
|
||||
import { filterAndSortResources } from '../lib/utils.js';
|
||||
|
||||
interface ResourceAutocompleteProps {
|
||||
resources: ResourceMetadata[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (resource: ResourceMetadata) => void;
|
||||
onHoverIndex?: (index: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ResourceAutocomplete({
|
||||
resources,
|
||||
query,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onHoverIndex,
|
||||
loading,
|
||||
}: ResourceAutocompleteProps) {
|
||||
const filtered = React.useMemo(
|
||||
() => filterAndSortResources(resources, query),
|
||||
[resources, query]
|
||||
);
|
||||
|
||||
const imageResourceUris = React.useMemo(
|
||||
() => filtered.filter((r) => (r.mimeType || '').startsWith('image/')).map((r) => r.uri),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
const imageResources = useResourceContent(imageResourceUris);
|
||||
|
||||
const itemRefs = React.useRef<HTMLLIElement[]>([]);
|
||||
React.useEffect(() => {
|
||||
const el = itemRefs.current[selectedIndex];
|
||||
if (el && el.scrollIntoView) {
|
||||
el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex, filtered.length]);
|
||||
|
||||
if (!query && filtered.length === 0 && !loading) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
<div>No resources available.</div>
|
||||
<div className="text-xs mt-1 text-muted-foreground/80">
|
||||
Connect an MCP server or enable internal resources to attach references.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate stable IDs for ARIA
|
||||
const getOptionId = (uri: string) =>
|
||||
`resource-option-${btoa(uri).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
const activeDescendant = filtered[selectedIndex]
|
||||
? getOptionId(filtered[selectedIndex].uri)
|
||||
: undefined;
|
||||
|
||||
return loading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Loading resources…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
<div>No resources match "{query}"</div>
|
||||
<div className="text-xs mt-1 text-muted-foreground/80">
|
||||
Tip: @ references only work at start or after spaces
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Resource suggestions"
|
||||
aria-activedescendant={activeDescendant}
|
||||
className="py-1 text-sm max-h-64 overflow-y-auto"
|
||||
>
|
||||
<li className="px-3 py-1.5 text-xs text-muted-foreground/80 border-b border-border">
|
||||
@ references files/resources • Works at start or after spaces
|
||||
</li>
|
||||
{filtered.map((r, idx) => {
|
||||
const optionId = getOptionId(r.uri);
|
||||
return (
|
||||
<li
|
||||
key={r.uri}
|
||||
id={optionId}
|
||||
role="option"
|
||||
aria-selected={idx === selectedIndex}
|
||||
ref={(node) => {
|
||||
if (node) itemRefs.current[idx] = node;
|
||||
}}
|
||||
className={
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-3 ' +
|
||||
(idx === selectedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground')
|
||||
}
|
||||
onMouseEnter={() => onHoverIndex?.(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(r);
|
||||
}}
|
||||
>
|
||||
{(r.mimeType || '').startsWith('image/') && (
|
||||
<ResourceThumbnail resourceState={imageResources[r.uri]} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="truncate font-medium">
|
||||
{r.name || r.uri.split('/').pop() || r.uri}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{r.uri}</div>
|
||||
</div>
|
||||
{r.serverName && (
|
||||
<span className="ml-auto shrink-0 rounded bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{r.serverName}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResourceThumbnailProps {
|
||||
resourceState?: ResourceState;
|
||||
}
|
||||
|
||||
function ResourceThumbnail({ resourceState }: ResourceThumbnailProps) {
|
||||
const baseClasses =
|
||||
'w-10 h-10 rounded-md border border-border bg-muted/40 flex items-center justify-center overflow-hidden flex-shrink-0';
|
||||
|
||||
if (!resourceState) {
|
||||
return (
|
||||
<div className={baseClasses}>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceState.status === 'loading') {
|
||||
return (
|
||||
<div className={baseClasses}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceState.status === 'error') {
|
||||
return (
|
||||
<div className={baseClasses} title={resourceState.error}>
|
||||
<ImageIcon className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageItem = resourceState.data?.items.find(
|
||||
(item): item is Extract<NormalizedResourceItem, { kind: 'image' }> => item.kind === 'image'
|
||||
);
|
||||
|
||||
if (!imageItem || !('src' in imageItem)) {
|
||||
return (
|
||||
<div className={baseClasses}>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClasses}>
|
||||
<img
|
||||
src={imageItem.src}
|
||||
alt={imageItem.alt || 'Resource preview'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
dexto/packages/webui/components/SearchPanel.tsx
Normal file
389
dexto/packages/webui/components/SearchPanel.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import {
|
||||
useSearchMessages,
|
||||
useSearchSessions,
|
||||
type SearchResult,
|
||||
type SessionSearchResult,
|
||||
} from './hooks/useSearch';
|
||||
import { formatDate, formatTime } from '@/lib/date-utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import {
|
||||
Search,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
User,
|
||||
Bot,
|
||||
Settings,
|
||||
X,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
|
||||
interface SearchPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNavigateToSession: (sessionId: string, messageIndex?: number) => void;
|
||||
variant?: 'inline' | 'modal' | 'popover';
|
||||
}
|
||||
|
||||
type SearchMode = 'messages' | 'sessions';
|
||||
|
||||
export default function SearchPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onNavigateToSession,
|
||||
variant = 'modal',
|
||||
}: SearchPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery] = useDebounce(searchQuery, 300);
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('messages');
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||
const [sessionFilter, setSessionFilter] = useState<string>('');
|
||||
|
||||
// Use TanStack Query hooks for search
|
||||
const {
|
||||
data: messageData,
|
||||
isLoading: messageLoading,
|
||||
error: messageError,
|
||||
} = useSearchMessages(
|
||||
debouncedQuery,
|
||||
sessionFilter || undefined,
|
||||
20,
|
||||
isOpen && searchMode === 'messages'
|
||||
);
|
||||
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading: sessionLoading,
|
||||
error: sessionError,
|
||||
} = useSearchSessions(debouncedQuery, isOpen && searchMode === 'sessions');
|
||||
|
||||
// Derive state from query results
|
||||
const messageResults = messageData?.results || [];
|
||||
const sessionResults = sessionData?.results || [];
|
||||
const isLoading = searchMode === 'messages' ? messageLoading : sessionLoading;
|
||||
const error = searchMode === 'messages' ? messageError : sessionError;
|
||||
const total = searchMode === 'messages' ? messageData?.total || 0 : sessionData?.total || 0;
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
onNavigateToSession(result.sessionId, result.messageIndex);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSessionResultClick = (sessionResult: SessionSearchResult) => {
|
||||
onNavigateToSession(sessionResult.sessionId, sessionResult.firstMatch.messageIndex);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return <User className="w-4 h-4" />;
|
||||
case 'assistant':
|
||||
return <Bot className="w-4 h-4" />;
|
||||
case 'system':
|
||||
return <Settings className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'assistant':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'system':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text;
|
||||
|
||||
const regex = new RegExp(`(${query})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={index} className="bg-yellow-200 font-medium">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={cn('flex flex-col h-full', variant === 'modal' && 'min-h-[600px]')}>
|
||||
{/* Search Input - moved to top for better UX */}
|
||||
<div className="p-4 border-b border-border/50 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Mode Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={searchMode === 'messages' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSearchMode('messages')}
|
||||
className="flex-1"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Messages
|
||||
</Button>
|
||||
<Button
|
||||
variant={searchMode === 'sessions' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSearchMode('sessions')}
|
||||
className="flex-1"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters for message search */}
|
||||
{searchMode === 'messages' && (
|
||||
<div className="flex gap-2">
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="assistant">Assistant</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Session ID (optional)"
|
||||
value={sessionFilter}
|
||||
onChange={(e) => setSessionFilter(e.target.value)}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error?.message || 'Search failed'}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mr-2" />
|
||||
<div className="text-muted-foreground">Searching...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Results Summary */}
|
||||
{searchQuery && (
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
Found {total}{' '}
|
||||
{searchMode === 'messages'
|
||||
? 'messages'
|
||||
: 'sessions'}{' '}
|
||||
matching "{searchQuery}"
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
No{' '}
|
||||
{searchMode === 'messages'
|
||||
? 'messages'
|
||||
: 'sessions'}{' '}
|
||||
found matching "{searchQuery}"
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Results */}
|
||||
{searchMode === 'messages' && messageResults.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{messageResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all cursor-pointer"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs',
|
||||
getRoleColor(result.message.role)
|
||||
)}
|
||||
>
|
||||
{getRoleIcon(result.message.role)}
|
||||
<span className="ml-1 capitalize">
|
||||
{result.message.role}
|
||||
</span>
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Session: {result.sessionId.slice(0, 8)}
|
||||
...
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
{highlightText(result.context, searchQuery)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Results */}
|
||||
{searchMode === 'sessions' && sessionResults.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sessionResults.map((sessionResult, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all cursor-pointer"
|
||||
onClick={() =>
|
||||
handleSessionResultClick(sessionResult)
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{sessionResult.sessionId.slice(0, 12)}
|
||||
...
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{sessionResult.matchCount} matches
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{sessionResult.metadata.messageCount} messages •
|
||||
Created{' '}
|
||||
{formatDate(sessionResult.metadata.createdAt)} •
|
||||
Last active{' '}
|
||||
{formatTime(
|
||||
sessionResult.metadata.lastActivity
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
{highlightText(
|
||||
sessionResult.firstMatch.context,
|
||||
searchQuery
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{searchQuery &&
|
||||
!isLoading &&
|
||||
(searchMode === 'messages'
|
||||
? messageResults.length === 0
|
||||
: sessionResults.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>
|
||||
No{' '}
|
||||
{searchMode === 'messages'
|
||||
? 'messages'
|
||||
: 'sessions'}{' '}
|
||||
found matching your search.
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
Try adjusting your search terms or filters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!searchQuery && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Start typing to search through your conversations.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
return <div className="h-full flex flex-col">{content}</div>;
|
||||
}
|
||||
|
||||
if (variant === 'popover') {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Popover panel */}
|
||||
<div className="fixed left-4 top-4 z-50 w-[400px] max-h-[80vh] bg-popover/95 backdrop-blur-md border border-border/50 rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<h3 className="text-sm font-medium">Search Chats</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="h-7 w-7 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[calc(80vh-48px)] overflow-y-auto">{content}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl h-[80vh] p-0">{content}</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
796
dexto/packages/webui/components/ServerRegistryModal.tsx
Normal file
796
dexto/packages/webui/components/ServerRegistryModal.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { serverRegistry } from '@/lib/serverRegistry';
|
||||
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import {
|
||||
Search,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Star,
|
||||
Server,
|
||||
Grid3X3,
|
||||
List,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Users,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ServerRegistryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInstallServer: (entry: ServerRegistryEntry) => Promise<'connected' | 'requires-input'>;
|
||||
onOpenConnectModal?: () => void;
|
||||
refreshTrigger?: number;
|
||||
disableClose?: boolean;
|
||||
}
|
||||
|
||||
export default function ServerRegistryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onInstallServer,
|
||||
onOpenConnectModal,
|
||||
refreshTrigger,
|
||||
disableClose = false,
|
||||
}: ServerRegistryModalProps) {
|
||||
const [entries, setEntries] = useState<ServerRegistryEntry[]>([]);
|
||||
const [filteredEntries, setFilteredEntries] = useState<ServerRegistryEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filter] = useState<ServerRegistryFilter>({});
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [expandedEntry, setExpandedEntry] = useState<string | null>(null);
|
||||
|
||||
// Ref for debouncing
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Cleanup effect to handle unmounting
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
// Abort any ongoing requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load entries when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const loadEntries = async () => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new AbortController for this request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Sync registry with current server status first
|
||||
await serverRegistry.syncWithServerStatus();
|
||||
|
||||
const registryEntries = await serverRegistry.getEntries();
|
||||
|
||||
// Check if component is still mounted and request wasn't aborted
|
||||
if (isMountedRef.current && !abortController.signal.aborted) {
|
||||
setEntries(registryEntries);
|
||||
setFilteredEntries(registryEntries);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Only set error if component is still mounted and request wasn't aborted
|
||||
if (isMountedRef.current && !abortController.signal.aborted) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to load server registry';
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
// Only update loading state if component is still mounted and request wasn't aborted
|
||||
if (isMountedRef.current && !abortController.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadEntries();
|
||||
}, [isOpen, refreshTrigger]);
|
||||
|
||||
// Debounced filter function
|
||||
const debouncedApplyFilters = useCallback(
|
||||
async (currentFilter: ServerRegistryFilter, currentSearchInput: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
try {
|
||||
const filtered = await serverRegistry.getEntries({
|
||||
...currentFilter,
|
||||
search: currentSearchInput || undefined,
|
||||
});
|
||||
|
||||
if (isMountedRef.current) setFilteredEntries(filtered);
|
||||
} catch (err: unknown) {
|
||||
if (isMountedRef.current) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to filter entries';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Apply filters with debouncing
|
||||
useEffect(() => {
|
||||
// Clear the previous timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set a new timer to debounce the filter operation
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debouncedApplyFilters(filter, searchInput);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
// Cleanup function to clear timer on unmount
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [filter, searchInput, entries, debouncedApplyFilters]);
|
||||
|
||||
// TODO: consolidate registry connection flows so modal + panels share a single state machine.
|
||||
const handleInstall = async (entry: ServerRegistryEntry) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setInstalling(entry.id);
|
||||
try {
|
||||
const result = await onInstallServer(entry);
|
||||
|
||||
if (result === 'connected') {
|
||||
await serverRegistry.setInstalled(entry.id, true);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: true } : e))
|
||||
);
|
||||
setFilteredEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: true } : e))
|
||||
);
|
||||
}
|
||||
} else if (isMountedRef.current) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: false } : e))
|
||||
);
|
||||
setFilteredEntries((prev) =>
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: false } : e))
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isMountedRef.current) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to install server';
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setInstalling(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Theme spec: Subtle, professional badge colors with soft backgrounds
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors = {
|
||||
productivity: 'bg-blue-50 text-blue-700 border-blue-200/60',
|
||||
development: 'bg-emerald-50 text-emerald-700 border-emerald-200/60',
|
||||
research: 'bg-purple-50 text-purple-700 border-purple-200/60',
|
||||
creative: 'bg-pink-50 text-pink-700 border-pink-200/60',
|
||||
data: 'bg-amber-50 text-amber-700 border-amber-200/60',
|
||||
communication: 'bg-indigo-50 text-indigo-700 border-indigo-200/60',
|
||||
custom: 'bg-slate-50 text-slate-700 border-slate-200/60',
|
||||
};
|
||||
return colors[category as keyof typeof colors] || colors.custom;
|
||||
};
|
||||
|
||||
const isCloseBlocked = disableClose || Boolean(installing);
|
||||
|
||||
const handleDialogOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open && !isCloseBlocked) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isCloseBlocked, onClose]
|
||||
);
|
||||
|
||||
const preventCloseInteraction = isCloseBlocked
|
||||
? (event: Event) => {
|
||||
event.preventDefault();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent
|
||||
className="!max-w-none w-[90vw] max-h-[85vh] overflow-hidden flex flex-col !sm:max-w-none p-0 bg-gradient-to-b from-background via-background to-muted/20"
|
||||
hideCloseButton
|
||||
onEscapeKeyDown={isCloseBlocked ? (event) => event.preventDefault() : undefined}
|
||||
onInteractOutside={preventCloseInteraction}
|
||||
>
|
||||
{/* Theme: Compact header with refined typography and subtle background */}
|
||||
<DialogHeader className="pb-4 border-b px-6 pt-5 bg-gradient-to-r from-muted/30 via-transparent to-transparent">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20 flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-lg font-semibold leading-tight mb-1">
|
||||
MCP Server Registry
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
Discover and add integrations to your AI assistant
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!isCloseBlocked) {
|
||||
onClose();
|
||||
}
|
||||
onOpenConnectModal?.();
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-sm font-medium hover:bg-muted whitespace-nowrap"
|
||||
>
|
||||
<PlusCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
Connect Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onClose()}
|
||||
disabled={isCloseBlocked}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Theme: Clean search with subtle focus state and refined controls */}
|
||||
<div className="flex gap-3 mb-5 px-6 pt-5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 pointer-events-none z-10" />
|
||||
<Input
|
||||
placeholder="Search servers and integrations..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-10 h-10 border focus:border-primary/40 bg-background text-sm placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex bg-muted/50 rounded-md p-1 border">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setViewMode('grid');
|
||||
setExpandedEntry(null);
|
||||
}}
|
||||
className={cn(
|
||||
'h-8 px-3 rounded-sm transition-all text-sm font-medium',
|
||||
viewMode === 'grid'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'hover:bg-background/50'
|
||||
)}
|
||||
>
|
||||
<Grid3X3 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setExpandedEntry(null);
|
||||
}}
|
||||
className={cn(
|
||||
'h-8 px-3 rounded-sm transition-all text-sm font-medium',
|
||||
viewMode === 'list'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'hover:bg-background/50'
|
||||
)}
|
||||
>
|
||||
<List className="h-3.5 w-3.5 mr-1.5" />
|
||||
List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-6 border-2 border-red-300 bg-red-50 shadow-md"
|
||||
>
|
||||
<AlertDescription className="text-red-800 font-medium">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex flex-col items-center space-y-5">
|
||||
<div className="h-12 w-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<div className="text-lg text-foreground font-semibold">
|
||||
Discovering servers...
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading registry entries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="p-6 rounded-2xl bg-gradient-to-br from-muted/50 to-muted/30 border-2 border-border/30 mb-6 shadow-sm">
|
||||
<Server className="h-14 w-14 text-muted-foreground/60 mx-auto" />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-foreground mb-3">
|
||||
{entries.length === 0
|
||||
? 'No servers available in the registry'
|
||||
: 'No servers match your search'}
|
||||
</div>
|
||||
<div className="text-base text-muted-foreground/80 mb-6 max-w-md">
|
||||
{entries.length === 0
|
||||
? 'The registry is currently empty or failed to load'
|
||||
: 'Try adjusting your search terms or browse all categories'}
|
||||
</div>
|
||||
{searchInput && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSearchInput('')}
|
||||
className="border-2 border-primary/30 hover:bg-primary/10 hover:border-primary/50 font-semibold"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
: 'space-y-3'
|
||||
)}
|
||||
>
|
||||
{filteredEntries.map((entry, index) => {
|
||||
const isExpanded = expandedEntry === entry.id;
|
||||
const hasLongDescription =
|
||||
entry.description && entry.description.length > 100;
|
||||
|
||||
return viewMode === 'grid' ? (
|
||||
<Card
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'group relative overflow-hidden transition-all duration-200 hover:-translate-y-0.5 border bg-card shadow-sm hover:shadow-lg hover:border-primary/30 flex flex-col',
|
||||
isExpanded &&
|
||||
'ring-1 ring-primary/20 border-primary/40 shadow-lg -translate-y-0.5'
|
||||
)}
|
||||
style={{ animationDelay: `${Math.min(index * 30, 300)}ms` }}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer flex flex-col flex-1"
|
||||
onClick={() =>
|
||||
setExpandedEntry(isExpanded ? null : entry.id)
|
||||
}
|
||||
>
|
||||
{/* Theme: Balanced header with medium emphasis icon and refined typography */}
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="text-2xl w-12 h-12 flex items-center justify-center rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 border border-primary/20 group-hover:border-primary/30 transition-all duration-200">
|
||||
{entry.icon || '⚡'}
|
||||
</div>
|
||||
{entry.isInstalled && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-card flex items-center justify-center">
|
||||
<CheckCircle className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-semibold mb-2 group-hover:text-primary transition-colors leading-snug line-clamp-2">
|
||||
{entry.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{entry.isOfficial && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0 bg-blue-50 text-blue-700 border-blue-200/60 font-medium"
|
||||
>
|
||||
<Star className="h-2.5 w-2.5 mr-1 fill-blue-400 text-blue-400" />
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
{entry.category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0 font-medium capitalize',
|
||||
getCategoryColor(
|
||||
entry.category
|
||||
)
|
||||
)}
|
||||
>
|
||||
{entry.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 flex flex-col flex-1">
|
||||
<div className="flex-1">
|
||||
{/* Theme: Readable description with proper line height */}
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground leading-relaxed transition-all duration-200',
|
||||
isExpanded ? '' : 'line-clamp-3'
|
||||
)}
|
||||
>
|
||||
{entry.description}
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 pt-3 mt-3 border-t border-border/30 animate-in slide-in-from-top-1 duration-200">
|
||||
{entry.author && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="p-1 rounded bg-muted/40">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground/70">
|
||||
by{' '}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{entry.author}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.tags &&
|
||||
entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.tags
|
||||
.slice(0, 6)
|
||||
.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0 text-muted-foreground border-border/40 bg-muted/20 font-normal"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{entry.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0 text-muted-foreground border-border/40 bg-muted/20"
|
||||
>
|
||||
+
|
||||
{entry.tags.length -
|
||||
6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.homepage && (
|
||||
<a
|
||||
href={entry.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Documentation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme: Clean footer with primary CTA emphasis */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30 flex-shrink-0 mt-auto">
|
||||
{hasLongDescription && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground p-0 h-auto font-medium -ml-1"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
More
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInstall(entry);
|
||||
}}
|
||||
disabled={
|
||||
entry.isInstalled ||
|
||||
installing === entry.id
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
entry.isInstalled
|
||||
? 'outline'
|
||||
: 'default'
|
||||
}
|
||||
className={cn(
|
||||
'h-8 px-4 transition-all font-semibold text-sm shadow-sm',
|
||||
installing === entry.id &&
|
||||
'opacity-70',
|
||||
entry.isInstalled &&
|
||||
'border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100',
|
||||
!entry.isInstalled &&
|
||||
'bg-primary hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{installing === entry.id ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
<span>Adding</span>
|
||||
</div>
|
||||
) : entry.isInstalled ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
<span>Added</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>Add</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:-translate-y-0.5 border bg-card shadow-sm hover:shadow-md hover:border-primary/30 cursor-pointer !py-0 !gap-0',
|
||||
isExpanded &&
|
||||
'border-primary/40 shadow-md -translate-y-0.5'
|
||||
)}
|
||||
onClick={() =>
|
||||
setExpandedEntry(isExpanded ? null : entry.id)
|
||||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 border border-primary/20 group-hover:border-primary/30 flex items-center justify-center text-2xl transition-all duration-200">
|
||||
{entry.icon || '⚡'}
|
||||
</div>
|
||||
{entry.isInstalled && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-card flex items-center justify-center">
|
||||
<CheckCircle className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
|
||||
{entry.name}
|
||||
</h3>
|
||||
{hasLongDescription && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground/60 transition-all duration-200 flex-shrink-0',
|
||||
isExpanded &&
|
||||
'rotate-180 text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{entry.isOfficial && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0 bg-blue-50 text-blue-700 border-blue-200/60 font-medium"
|
||||
>
|
||||
<Star className="h-2.5 w-2.5 mr-1 fill-blue-400 text-blue-400" />
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
{entry.category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0 font-medium capitalize',
|
||||
getCategoryColor(
|
||||
entry.category
|
||||
)
|
||||
)}
|
||||
>
|
||||
{entry.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInstall(entry);
|
||||
}}
|
||||
disabled={
|
||||
entry.isInstalled ||
|
||||
installing === entry.id
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
entry.isInstalled
|
||||
? 'outline'
|
||||
: 'default'
|
||||
}
|
||||
className={cn(
|
||||
'h-8 px-4 text-sm font-semibold transition-all flex-shrink-0 shadow-sm',
|
||||
installing === entry.id &&
|
||||
'opacity-70',
|
||||
entry.isInstalled &&
|
||||
'border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100',
|
||||
!entry.isInstalled &&
|
||||
'bg-primary hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{installing === entry.id ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
<span>Adding</span>
|
||||
</div>
|
||||
) : entry.isInstalled ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
<span>Added</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>Add</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground leading-relaxed transition-all duration-200',
|
||||
isExpanded ? '' : 'line-clamp-2'
|
||||
)}
|
||||
>
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 pt-3 border-t border-border/30 animate-in slide-in-from-top-2 duration-200">
|
||||
{entry.author && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="p-1 rounded bg-muted/40">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground/70">
|
||||
by{' '}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{entry.author}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.tags &&
|
||||
entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs px-2 py-0.5 text-muted-foreground border-border/40 bg-muted/20 font-normal"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.homepage && (
|
||||
<a
|
||||
href={entry.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Documentation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1060
dexto/packages/webui/components/ServersPanel.tsx
Normal file
1060
dexto/packages/webui/components/ServersPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
688
dexto/packages/webui/components/SessionPanel.tsx
Normal file
688
dexto/packages/webui/components/SessionPanel.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
useSessions,
|
||||
useCreateSession,
|
||||
useDeleteSession,
|
||||
useRenameSession,
|
||||
type Session,
|
||||
} from './hooks/useSessions';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import {
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
History,
|
||||
Search,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
FlaskConical,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExpand?: () => void;
|
||||
currentSessionId?: string | null;
|
||||
onSessionChange: (sessionId: string) => void;
|
||||
returnToWelcome: () => void;
|
||||
variant?: 'inline' | 'overlay';
|
||||
onSearchOpen?: () => void;
|
||||
onNewChat?: () => void;
|
||||
// App-level actions
|
||||
onSettingsOpen?: () => void;
|
||||
onPlaygroundOpen?: () => void;
|
||||
onThemeToggle?: () => void;
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
function sortSessions(sessions: Session[]): Session[] {
|
||||
return sessions.sort((a, b) => {
|
||||
const timeA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
|
||||
const timeB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
export default function SessionPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onExpand,
|
||||
currentSessionId,
|
||||
onSessionChange,
|
||||
returnToWelcome,
|
||||
variant = 'overlay',
|
||||
onSearchOpen,
|
||||
onNewChat,
|
||||
onSettingsOpen,
|
||||
onPlaygroundOpen,
|
||||
onThemeToggle,
|
||||
theme,
|
||||
}: SessionPanelProps) {
|
||||
const [isNewSessionOpen, setNewSessionOpen] = useState(false);
|
||||
const [newSessionId, setNewSessionId] = useState('');
|
||||
const [isDeleteConversationDialogOpen, setDeleteConversationDialogOpen] = useState(false);
|
||||
const [selectedSessionForAction, setSelectedSessionForAction] = useState<string | null>(null);
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [copiedSessionId, setCopiedSessionId] = useState<string | null>(null);
|
||||
|
||||
const { data: sessionsData = [], isLoading: loading, error } = useSessions(isOpen);
|
||||
|
||||
// Sort sessions by last activity for display
|
||||
const sessions = sortSessions([...sessionsData]);
|
||||
|
||||
const createSessionMutation = useCreateSession();
|
||||
const deleteSessionMutation = useDeleteSession();
|
||||
const renameSessionMutation = useRenameSession();
|
||||
|
||||
// Note: Agent switch invalidation is now handled centrally in AgentSelector
|
||||
// Message/response/title events are handled in useChat via direct cache updates
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
const newSession = await createSessionMutation.mutateAsync({
|
||||
sessionId: newSessionId.trim() || undefined,
|
||||
});
|
||||
setNewSessionId('');
|
||||
setNewSessionOpen(false);
|
||||
onSessionChange(newSession.id);
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
await deleteSessionMutation.mutateAsync({ sessionId });
|
||||
const isDeletingCurrentSession = currentSessionId === sessionId;
|
||||
if (isDeletingCurrentSession) {
|
||||
returnToWelcome();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConversation = async () => {
|
||||
if (!selectedSessionForAction) return;
|
||||
await deleteSessionMutation.mutateAsync({ sessionId: selectedSessionForAction });
|
||||
const isDeletingCurrentSession = currentSessionId === selectedSessionForAction;
|
||||
if (isDeletingCurrentSession) {
|
||||
returnToWelcome();
|
||||
}
|
||||
setDeleteConversationDialogOpen(false);
|
||||
setSelectedSessionForAction(null);
|
||||
};
|
||||
|
||||
const handleOpenRenameDialog = (sessionId: string, currentTitle: string | null) => {
|
||||
setSelectedSessionForAction(sessionId);
|
||||
setRenameValue(currentTitle || '');
|
||||
setRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRenameSession = async () => {
|
||||
if (!selectedSessionForAction || !renameValue.trim()) return;
|
||||
try {
|
||||
await renameSessionMutation.mutateAsync({
|
||||
sessionId: selectedSessionForAction,
|
||||
title: renameValue.trim(),
|
||||
});
|
||||
setRenameDialogOpen(false);
|
||||
setSelectedSessionForAction(null);
|
||||
setRenameValue('');
|
||||
} catch (error) {
|
||||
// Error is already logged by React Query, keep dialog open for retry
|
||||
console.error(`Failed to rename session: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySessionId = async (sessionId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId);
|
||||
setCopiedSessionId(sessionId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy session ID: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up copy feedback timeout
|
||||
useEffect(() => {
|
||||
if (copiedSessionId) {
|
||||
const timeoutId = setTimeout(() => setCopiedSessionId(null), 2000);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [copiedSessionId]);
|
||||
|
||||
const formatRelativeTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return 'Unknown';
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with Dexto branding */}
|
||||
<div className="px-4 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Dexto logo */}
|
||||
<div id="sessionpanel-title" className="flex items-center px-2">
|
||||
{/* Light mode logo */}
|
||||
<img
|
||||
src="/logos/dexto/dexto_logo_light.svg"
|
||||
alt="Dexto"
|
||||
className="h-6 w-auto dark:hidden"
|
||||
/>
|
||||
{/* Dark mode logo */}
|
||||
<img
|
||||
src="/logos/dexto/dexto_logo.svg"
|
||||
alt="Dexto"
|
||||
className="h-6 w-auto hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label="Collapse panel"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions List */}
|
||||
<ScrollArea className="flex-1 scrollbar-thin">
|
||||
{/* Action items at top of list */}
|
||||
<div className="px-3 pt-2 pb-1 space-y-0.5">
|
||||
{onNewChat && (
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
)}
|
||||
{onSearchOpen && (
|
||||
<button
|
||||
onClick={onSearchOpen}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
{(onNewChat || onSearchOpen) && <div className="h-2" />}
|
||||
|
||||
{/* History Header */}
|
||||
{!loading && sessions.length > 0 && (
|
||||
<div className="px-4 py-2">
|
||||
<h2 className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||
History
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-12 px-6">
|
||||
<History className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">No conversations yet</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Start chatting to see your history
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
{sessions.map((session) => {
|
||||
const title =
|
||||
session.title && session.title.trim().length > 0
|
||||
? session.title
|
||||
: session.id;
|
||||
const isActive = currentSessionId === session.id;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'group relative px-3 py-1.5 rounded-lg transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary/5 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary before:rounded-full'
|
||||
: 'hover:bg-muted/40'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onSessionChange(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSessionChange(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3
|
||||
className={cn(
|
||||
'text-sm truncate flex-1 min-w-0',
|
||||
isActive
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Timestamp - hidden on hover */}
|
||||
<span className="text-[10px] text-muted-foreground/50 shrink-0 group-hover:opacity-0 transition-opacity">
|
||||
{formatRelativeTime(session.lastActivity)}
|
||||
</span>
|
||||
|
||||
{/* Dropdown - shown on hover, positioned to overlap timestamp */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-7 w-7 p-0 absolute right-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
aria-label="Session options"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleOpenRenameDialog(
|
||||
session.id,
|
||||
session.title ?? null
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCopySessionId(session.id)}
|
||||
>
|
||||
{copiedSessionId === session.id ? (
|
||||
<Check className="h-4 w-4 mr-2 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{copiedSessionId === session.id
|
||||
? 'Copied!'
|
||||
: 'Copy Session ID'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (session.messageCount > 0) {
|
||||
setSelectedSessionForAction(session.id);
|
||||
setDeleteConversationDialogOpen(true);
|
||||
} else {
|
||||
handleDeleteSession(session.id);
|
||||
}
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer with app-level actions */}
|
||||
<div className="border-t border-border/30 p-3 space-y-1">
|
||||
{/* Developer Tools */}
|
||||
{onPlaygroundOpen && (
|
||||
<button
|
||||
onClick={onPlaygroundOpen}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
<span>MCP Playground</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{onPlaygroundOpen && (onThemeToggle || onSettingsOpen) && (
|
||||
<div className="h-px bg-border/30 my-1" />
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{onThemeToggle && (
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-4 w-4 transition-transform duration-200 hover:rotate-180" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 transition-transform duration-200 hover:rotate-12" />
|
||||
)}
|
||||
<span>{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{onSettingsOpen && (
|
||||
<button
|
||||
onClick={onSettingsOpen}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Chat Dialog */}
|
||||
<Dialog open={isNewSessionOpen} onOpenChange={setNewSessionOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start New Chat</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sessionId">Chat ID</Label>
|
||||
<Input
|
||||
id="sessionId"
|
||||
value={newSessionId}
|
||||
onChange={(e) => setNewSessionId(e.target.value)}
|
||||
placeholder="e.g., user-123, project-alpha"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to auto-generate a unique ID
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewSessionOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateSession}>Start Chat</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Conversation Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={isDeleteConversationDialogOpen}
|
||||
onOpenChange={setDeleteConversationDialogOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>Delete Conversation</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete this conversation and all its messages.
|
||||
This action cannot be undone.
|
||||
{selectedSessionForAction && (
|
||||
<span className="block mt-2 font-medium">
|
||||
Session:{' '}
|
||||
<span className="font-mono">{selectedSessionForAction}</span>
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConversationDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConversation}
|
||||
disabled={
|
||||
deleteSessionMutation.isPending &&
|
||||
deleteSessionMutation.variables?.sessionId ===
|
||||
selectedSessionForAction
|
||||
}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>
|
||||
{deleteSessionMutation.isPending &&
|
||||
deleteSessionMutation.variables?.sessionId ===
|
||||
selectedSessionForAction
|
||||
? 'Deleting...'
|
||||
: 'Delete Conversation'}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Session Dialog */}
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2">
|
||||
<Pencil className="h-5 w-5" />
|
||||
<span>Rename Chat</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for this conversation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="renameTitle">Chat Name</Label>
|
||||
<Input
|
||||
id="renameTitle"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="Enter chat name..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && renameValue.trim()) {
|
||||
handleRenameSession();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRenameSession}
|
||||
disabled={!renameValue.trim() || renameSessionMutation.isPending}
|
||||
>
|
||||
{renameSessionMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Collapsed sidebar content - thin bar with icon buttons
|
||||
const collapsedContent = (
|
||||
<div className="flex flex-col h-full py-3 px-2 items-center">
|
||||
{/* Dexto icon - click to expand */}
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg hover:bg-muted/40 transition-colors mb-3"
|
||||
aria-label="Expand panel"
|
||||
>
|
||||
<img src="/logos/dexto/dexto_logo_icon.svg" alt="Dexto" className="h-7 w-7" />
|
||||
</button>
|
||||
|
||||
{/* Action items with subtle spacing */}
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{/* New Chat */}
|
||||
{onNewChat && (
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label="New chat"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{onSearchOpen && (
|
||||
<button
|
||||
onClick={onSearchOpen}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions - playground, theme, settings */}
|
||||
<div className="flex flex-col gap-1 pt-2 border-t border-border/30">
|
||||
{/* Playground */}
|
||||
{onPlaygroundOpen && (
|
||||
<button
|
||||
onClick={onPlaygroundOpen}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label="MCP Playground"
|
||||
>
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{onThemeToggle && (
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label={theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{onSettingsOpen && (
|
||||
<button
|
||||
onClick={onSettingsOpen}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// For inline variant, show collapsed or expanded
|
||||
if (variant === 'inline') {
|
||||
if (!isOpen) {
|
||||
// Collapsed state - thin bar
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card border-r border-border/30">
|
||||
{collapsedContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Expanded state - full panel
|
||||
return <div className="h-full w-full flex flex-col bg-card">{content}</div>;
|
||||
}
|
||||
|
||||
// Overlay variant with slide animation
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 z-30 transition-opacity duration-300',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sessionpanel-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-40 h-screen w-72 bg-card border-r border-border shadow-xl transition-transform duration-300 ease-in-out flex flex-col',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
dexto/packages/webui/components/SettingsModal.tsx
Normal file
36
dexto/packages/webui/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import { SpeechVoiceSelect } from './ui/speech-voice-select';
|
||||
|
||||
type SettingsModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure preferences for speech and more.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
<section className="space-y-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground">Voice</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a preferred text-to-speech voice. “Auto” selects the best
|
||||
available voice on your device.
|
||||
</p>
|
||||
<SpeechVoiceSelect active={isOpen} />
|
||||
</section>
|
||||
|
||||
{/* TODO: Future settings (e.g., streaming, theme, hotkeys) */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
436
dexto/packages/webui/components/SlashCommandAutocomplete.tsx
Normal file
436
dexto/packages/webui/components/SlashCommandAutocomplete.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Sparkles, Zap, Plus } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import type { PromptInfo as CorePromptInfo } from '@dexto/core';
|
||||
import { usePrompts } from './hooks/usePrompts';
|
||||
|
||||
// Use canonical types from @dexto/core for alignment
|
||||
type PromptInfo = CorePromptInfo;
|
||||
|
||||
// PromptItem component for rendering individual prompts
|
||||
const PromptItem = ({
|
||||
prompt,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
dataIndex,
|
||||
}: {
|
||||
prompt: Prompt;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
dataIndex?: number;
|
||||
}) => (
|
||||
<div
|
||||
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-primary/20 ring-1 ring-primary/40' : 'hover:bg-primary/10'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
data-index={dataIndex}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{prompt.source === 'mcp' ? (
|
||||
<Zap className="h-3 w-3 text-blue-400" />
|
||||
) : prompt.source === 'config' ? (
|
||||
<span className="text-xs">📋</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-purple-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{/* Command name with inline arguments */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Use commandName (collision-resolved) for display, fall back to displayName/name */}
|
||||
<span className="font-medium text-xs text-foreground">
|
||||
/{prompt.commandName || prompt.displayName || prompt.name}
|
||||
</span>
|
||||
{prompt.arguments && prompt.arguments.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
{prompt.arguments.map((arg) => (
|
||||
<span
|
||||
key={arg.name}
|
||||
className="group relative inline-block"
|
||||
title={arg.description || arg.name}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground/70 hover:text-muted-foreground cursor-help transition-colors">
|
||||
<{arg.name}
|
||||
{arg.required ? '' : '?'}>
|
||||
</span>
|
||||
{/* Tooltip on hover */}
|
||||
{arg.description && (
|
||||
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 z-50 px-2 py-1 text-[10px] bg-popover text-popover-foreground border border-border rounded shadow-lg whitespace-nowrap pointer-events-none">
|
||||
{arg.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source badges */}
|
||||
{prompt.source === 'mcp' && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5 h-4">
|
||||
MCP
|
||||
</Badge>
|
||||
)}
|
||||
{prompt.source === 'config' && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5 h-4">
|
||||
Config
|
||||
</Badge>
|
||||
)}
|
||||
{prompt.source === 'custom' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0.5 h-4 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show title if available */}
|
||||
{prompt.title && (
|
||||
<div className="text-xs font-medium text-foreground/90 mb-0.5">
|
||||
{prompt.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show description if available and different from title */}
|
||||
{prompt.description && prompt.description !== prompt.title && (
|
||||
<div className="text-xs text-muted-foreground mb-1.5 line-clamp-2">
|
||||
{prompt.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Define UI-specific Prompt interface extending core PromptInfo
|
||||
interface Prompt extends PromptInfo {
|
||||
// UI-specific fields that may come from metadata
|
||||
starterPrompt?: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface SlashCommandAutocompleteProps {
|
||||
isVisible: boolean;
|
||||
searchQuery: string;
|
||||
onSelectPrompt: (prompt: Prompt) => void;
|
||||
onClose: () => void;
|
||||
onCreatePrompt?: () => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function SlashCommandAutocomplete({
|
||||
isVisible,
|
||||
searchQuery,
|
||||
onSelectPrompt,
|
||||
onClose,
|
||||
onCreatePrompt,
|
||||
refreshKey,
|
||||
}: SlashCommandAutocompleteProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selectedIndexRef = useRef(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastRefreshKeyRef = useRef<number>(0);
|
||||
|
||||
// Fetch prompts using TanStack Query
|
||||
const { data: prompts = [], isLoading, refetch } = usePrompts({ enabled: isVisible });
|
||||
|
||||
// Keep the latest selected index accessible in callbacks without needing extra effect deps
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
// Refetch when refreshKey changes
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const effectiveKey = refreshKey ?? 0;
|
||||
if (effectiveKey > 0 && effectiveKey !== lastRefreshKeyRef.current) {
|
||||
refetch();
|
||||
lastRefreshKeyRef.current = effectiveKey;
|
||||
}
|
||||
}, [isVisible, refreshKey, refetch]);
|
||||
|
||||
// Filter prompts based on search query - memoized to avoid infinite loops
|
||||
const filteredPrompts = React.useMemo(() => {
|
||||
if (!searchQuery.trim() || searchQuery === '/') {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
// Extract just the command name (first word after /) for filtering
|
||||
// E.g., "/summarize technical 100 'text'" -> "summarize"
|
||||
const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery;
|
||||
const commandName = withoutSlash.split(/\s+/)[0] || '';
|
||||
|
||||
return prompts.filter(
|
||||
(prompt) =>
|
||||
prompt.name.toLowerCase().includes(commandName.toLowerCase()) ||
|
||||
(prompt.description &&
|
||||
prompt.description.toLowerCase().includes(commandName.toLowerCase())) ||
|
||||
(prompt.title && prompt.title.toLowerCase().includes(commandName.toLowerCase()))
|
||||
);
|
||||
}, [searchQuery, prompts]);
|
||||
|
||||
const showCreateOption = React.useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === '/') return true;
|
||||
if (trimmed.startsWith('/') && filteredPrompts.length === 0) return true;
|
||||
return false;
|
||||
}, [searchQuery, filteredPrompts.length]);
|
||||
|
||||
const combinedItems = React.useMemo(() => {
|
||||
const items: Array<{ kind: 'create' } | { kind: 'prompt'; prompt: Prompt }> = [];
|
||||
if (showCreateOption) {
|
||||
items.push({ kind: 'create' });
|
||||
}
|
||||
filteredPrompts.forEach((prompt) => items.push({ kind: 'prompt', prompt }));
|
||||
return items;
|
||||
}, [showCreateOption, filteredPrompts]);
|
||||
|
||||
// Note: mcp:prompts-list-changed DOM listener removed (was dead code - never dispatched as DOM event)
|
||||
// Prompts are refreshed via React Query's built-in mechanisms when needed
|
||||
|
||||
// Reset selected index when filtered results change
|
||||
useEffect(() => {
|
||||
const shouldShowCreate = searchQuery === '/';
|
||||
const defaultIndex = shouldShowCreate && filteredPrompts.length > 0 ? 1 : 0;
|
||||
setSelectedIndex(defaultIndex);
|
||||
}, [searchQuery, filteredPrompts.length]);
|
||||
|
||||
const itemsLength = combinedItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex((prevIndex) => {
|
||||
if (itemsLength === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (prevIndex >= itemsLength) {
|
||||
return itemsLength - 1;
|
||||
}
|
||||
|
||||
return prevIndex;
|
||||
});
|
||||
}, [itemsLength]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const items = combinedItems;
|
||||
const stop = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Some environments support stopImmediatePropagation on DOM events
|
||||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
// Check if user has typed arguments after the command name
|
||||
// E.g., "/summarize technical 100 'text'" -> has arguments, so Enter should submit
|
||||
const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery;
|
||||
const parts = withoutSlash.split(/\s+/);
|
||||
const hasArguments =
|
||||
parts.length > 1 && parts.slice(1).some((p) => p.trim().length > 0);
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
if (items.length === 0) return;
|
||||
stop();
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (items.length === 0) return;
|
||||
stop();
|
||||
setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
// If user has typed arguments, let Enter pass through to submit the message
|
||||
if (hasArguments) {
|
||||
return; // Don't intercept - let InputArea handle submission
|
||||
}
|
||||
stop();
|
||||
if (items.length === 0) {
|
||||
onCreatePrompt?.();
|
||||
return;
|
||||
}
|
||||
{
|
||||
const item = items[selectedIndexRef.current];
|
||||
if (item.kind === 'create') {
|
||||
onCreatePrompt?.();
|
||||
} else {
|
||||
onSelectPrompt(item.prompt);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
stop();
|
||||
onClose();
|
||||
break;
|
||||
case 'Tab':
|
||||
stop();
|
||||
if (items.length === 0) {
|
||||
onCreatePrompt?.();
|
||||
return;
|
||||
}
|
||||
{
|
||||
const item = items[selectedIndexRef.current];
|
||||
if (item.kind === 'create') {
|
||||
onCreatePrompt?.();
|
||||
} else {
|
||||
onSelectPrompt(item.prompt);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase so we can intercept Enter before input handlers stop propagation
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isVisible, combinedItems, onSelectPrompt, onClose, onCreatePrompt, searchQuery]);
|
||||
|
||||
// Scroll selected item into view when selectedIndex changes
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const selectedItem = scrollContainer.querySelector(
|
||||
`[data-index="${selectedIndex}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
if (selectedItem) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const itemRect = selectedItem.getBoundingClientRect();
|
||||
|
||||
// Check if item is visible in container
|
||||
const isAbove = itemRect.top < containerRect.top;
|
||||
const isBelow = itemRect.bottom > containerRect.bottom;
|
||||
|
||||
if (isAbove || isBelow) {
|
||||
selectedItem.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute left-0 right-0 mb-2 bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-hidden z-[9999]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 0px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderRadius: '8px',
|
||||
maxHeight: '320px',
|
||||
overflow: 'visible',
|
||||
zIndex: 9999,
|
||||
minWidth: '400px',
|
||||
// Custom dark styling
|
||||
background:
|
||||
'linear-gradient(135deg, hsl(var(--background)) 0%, hsl(var(--muted)) 100%)',
|
||||
border: '1px solid hsl(var(--border) / 0.3)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
boxShadow:
|
||||
'0 8px 32px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Header - Compact with prompt count */}
|
||||
<div className="px-3 py-2 border-b border-border bg-muted/50">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Available Prompts (hover over arguments for more info)</span>
|
||||
<Badge variant="secondary" className="ml-auto text-xs px-2 py-0.5">
|
||||
{prompts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompts List */}
|
||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
Loading prompts...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showCreateOption && (
|
||||
<div
|
||||
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||
selectedIndex === 0
|
||||
? 'bg-primary/20 ring-1 ring-primary/40'
|
||||
: 'hover:bg-primary/10'
|
||||
}`}
|
||||
onClick={() => onCreatePrompt?.()}
|
||||
onMouseEnter={() => setSelectedIndex(0)}
|
||||
data-index={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||
<Plus className="h-3 w-3 text-primary" />
|
||||
<span>Create new prompt</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
Define a reusable prompt. Press Enter to continue.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredPrompts.length === 0
|
||||
? !showCreateOption && (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
No prompts available.
|
||||
</div>
|
||||
)
|
||||
: filteredPrompts.map((prompt, index) => {
|
||||
const itemIndex = showCreateOption ? index + 1 : index;
|
||||
return (
|
||||
<PromptItem
|
||||
key={prompt.name}
|
||||
prompt={prompt}
|
||||
isSelected={itemIndex === selectedIndex}
|
||||
onClick={() => onSelectPrompt(prompt)}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
dataIndex={itemIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Compact with navigation hints */}
|
||||
<div className="px-2 py-1.5 border-t border-border bg-muted/20 text-xs text-muted-foreground text-center">
|
||||
<span>↑↓ Navigate • Tab/Enter Select • Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
dexto/packages/webui/components/ThemeSwitch.tsx
Normal file
54
dexto/packages/webui/components/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as Switch from '@radix-ui/react-switch';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Don't render switch until after hydration to avoid mismatch
|
||||
if (!mounted) {
|
||||
return <div className="w-12 h-6 bg-gray-300 dark:bg-gray-700 rounded-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Switch.Root
|
||||
checked={isDark}
|
||||
onCheckedChange={toggleTheme}
|
||||
className="w-12 h-6 bg-gray-300 dark:bg-gray-700 rounded-full relative transition-colors flex items-center px-0.5"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Switch.Thumb
|
||||
className={`
|
||||
w-5 h-5 rounded-full shadow flex items-center justify-center
|
||||
transition-transform transform
|
||||
translate-x-0.5 data-[state=checked]:translate-x-[1.375rem]
|
||||
bg-white dark:bg-gray-100
|
||||
`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Moon className="w-3.5 h-3.5 text-gray-700" />
|
||||
) : (
|
||||
<Sun className="w-3.5 h-3.5 text-yellow-500" />
|
||||
)}
|
||||
</Switch.Thumb>
|
||||
</Switch.Root>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
144
dexto/packages/webui/components/Toast/ToastContainer.tsx
Normal file
144
dexto/packages/webui/components/Toast/ToastContainer.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Toast Container
|
||||
*
|
||||
* Displays toast notifications in the bottom-right corner.
|
||||
* Handles auto-dismiss, manual dismiss, and "Go to session" actions.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useNotificationStore, type Toast } from '@/lib/stores/notificationStore';
|
||||
import { useSessionStore } from '@/lib/stores/sessionStore';
|
||||
import { X, AlertTriangle, CheckCircle2, Info, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Intent color mappings
|
||||
*/
|
||||
const intentStyles = {
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/50',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
icon: 'text-blue-600 dark:text-blue-400',
|
||||
iconComponent: Info,
|
||||
},
|
||||
success: {
|
||||
bg: 'bg-green-50 dark:bg-green-950/50',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
iconComponent: CheckCircle2,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-950/50',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
icon: 'text-yellow-600 dark:text-yellow-400',
|
||||
iconComponent: AlertTriangle,
|
||||
},
|
||||
danger: {
|
||||
bg: 'bg-red-50 dark:bg-red-950/50',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
icon: 'text-red-600 dark:text-red-400',
|
||||
iconComponent: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual toast item component
|
||||
*/
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { setCurrentSession } = useSessionStore();
|
||||
|
||||
// Auto-dismiss after duration
|
||||
useEffect(() => {
|
||||
const duration = toast.duration || 5000;
|
||||
const timer = setTimeout(onDismiss, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.duration, onDismiss]);
|
||||
|
||||
// Navigate to session
|
||||
const handleGoToSession = useCallback(() => {
|
||||
if (toast.sessionId) {
|
||||
setCurrentSession(toast.sessionId);
|
||||
navigate({ to: '/' });
|
||||
onDismiss();
|
||||
}
|
||||
}, [toast.sessionId, setCurrentSession, navigate, onDismiss]);
|
||||
|
||||
const styles = intentStyles[toast.intent];
|
||||
const IconComponent = styles.iconComponent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
|
||||
'min-w-[320px] max-w-[420px]',
|
||||
'animate-in slide-in-from-right-full duration-300',
|
||||
styles.bg,
|
||||
styles.border
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Icon */}
|
||||
<IconComponent className={cn('w-5 h-5 flex-shrink-0 mt-0.5', styles.icon)} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{toast.title}
|
||||
</div>
|
||||
{toast.description && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 mt-1">
|
||||
{toast.description}
|
||||
</div>
|
||||
)}
|
||||
{toast.sessionId && (
|
||||
<button
|
||||
onClick={handleGoToSession}
|
||||
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline mt-2"
|
||||
>
|
||||
Go to session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast container component
|
||||
*
|
||||
* Renders all active toasts in a fixed bottom-right position.
|
||||
*/
|
||||
export function ToastContainer() {
|
||||
const { toasts, removeToast } = useNotificationStore();
|
||||
|
||||
if (toasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} onDismiss={() => removeToast(toast.id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
dexto/packages/webui/components/Toast/index.ts
Normal file
7
dexto/packages/webui/components/Toast/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Toast Components
|
||||
*
|
||||
* Barrel export for toast notification components.
|
||||
*/
|
||||
|
||||
export { ToastContainer } from './ToastContainer.js';
|
||||
107
dexto/packages/webui/components/TodoPanel.tsx
Normal file
107
dexto/packages/webui/components/TodoPanel.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TodoPanel Component (WebUI)
|
||||
* Displays agent's todo list with progress tracking
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { Card } from './ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTodoStore, type Todo } from '@/lib/stores/todoStore';
|
||||
|
||||
interface TodoPanelProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Stable empty array to avoid infinite re-render loop
|
||||
const EMPTY_TODOS: Todo[] = [];
|
||||
|
||||
/**
|
||||
* Compact todo panel showing task progress
|
||||
* Shows up to 10 tasks with minimal spacing
|
||||
*/
|
||||
export function TodoPanel({ sessionId }: TodoPanelProps) {
|
||||
// Select directly from state, use stable empty array fallback outside selector
|
||||
const todos = useTodoStore((state) => state.sessions.get(sessionId)?.todos) ?? EMPTY_TODOS;
|
||||
|
||||
if (todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedCount = todos.filter((t) => t.status === 'completed').length;
|
||||
const totalCount = todos.length;
|
||||
|
||||
// Show up to 10 tasks total
|
||||
const visibleTodos = todos.slice(0, 10);
|
||||
const hasMore = todos.length > 10;
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-amber-500 dark:border-l-amber-600 border-t border-r border-b border-border bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<div className="p-3 space-y-2.5">
|
||||
{/* Header with progress */}
|
||||
<div className="flex items-center justify-between border-b border-amber-200 dark:border-amber-900/50 pb-2">
|
||||
<span className="text-sm font-semibold text-foreground tracking-tight">
|
||||
Tasks in Progress
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-16 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-500 to-orange-500 dark:from-amber-600 dark:to-orange-600 transition-all duration-300"
|
||||
style={{
|
||||
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All tasks */}
|
||||
<div className="space-y-1.5">
|
||||
{visibleTodos.map((todo) => {
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
const isCompleted = todo.status === 'completed';
|
||||
return (
|
||||
<div key={todo.id} className="flex items-start gap-2.5 group">
|
||||
<div className="mt-0.5">
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
|
||||
) : isInProgress ? (
|
||||
<div className="h-3.5 w-3.5 rounded-full border-2 border-amber-500 dark:border-amber-600 bg-amber-500/20 dark:bg-amber-600/20 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-600" />
|
||||
</div>
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm leading-relaxed flex-1',
|
||||
isCompleted && 'line-through text-muted-foreground/60',
|
||||
isInProgress && 'text-foreground font-medium',
|
||||
!isCompleted && !isInProgress && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isInProgress ? todo.activeForm : todo.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* More tasks indicator */}
|
||||
{hasMore && (
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
<span className="text-xs text-muted-foreground/70 italic">
|
||||
+{todos.length - 10} more tasks...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
936
dexto/packages/webui/components/ToolCallTimeline.tsx
Normal file
936
dexto/packages/webui/components/ToolCallTimeline.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
FileText,
|
||||
FileEdit,
|
||||
FilePlus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { CodePreview } from './CodePreview';
|
||||
import type { ToolDisplayData } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Sub-agent progress data for spawn_agent tool calls
|
||||
*/
|
||||
export interface SubAgentProgress {
|
||||
task: string;
|
||||
agentId: string;
|
||||
toolsCalled: number;
|
||||
currentTool: string;
|
||||
currentArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallTimelineProps {
|
||||
toolName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolResult?: unknown;
|
||||
success?: boolean;
|
||||
requireApproval?: boolean;
|
||||
approvalStatus?: 'pending' | 'approved' | 'rejected';
|
||||
displayData?: ToolDisplayData;
|
||||
subAgentProgress?: SubAgentProgress;
|
||||
onApprove?: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function stripToolPrefix(toolName: string): { displayName: string; source: string } {
|
||||
if (toolName.startsWith('internal--')) {
|
||||
return { displayName: toolName.replace('internal--', ''), source: '' };
|
||||
}
|
||||
if (toolName.startsWith('custom--')) {
|
||||
return { displayName: toolName.replace('custom--', ''), source: '' };
|
||||
}
|
||||
if (toolName.startsWith('mcp--')) {
|
||||
const parts = toolName.split('--');
|
||||
if (parts.length >= 3) {
|
||||
return { displayName: parts.slice(2).join('--'), source: parts[1] ?? '' };
|
||||
}
|
||||
return { displayName: toolName.replace('mcp--', ''), source: 'mcp' };
|
||||
}
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
const parts = toolName.substring(5).split('__');
|
||||
if (parts.length >= 2) {
|
||||
return { displayName: parts.slice(1).join('__'), source: parts[0] ?? '' };
|
||||
}
|
||||
return { displayName: toolName.substring(5), source: 'mcp' };
|
||||
}
|
||||
if (toolName.startsWith('internal__')) {
|
||||
return { displayName: toolName.substring(10), source: '' };
|
||||
}
|
||||
return { displayName: toolName, source: '' };
|
||||
}
|
||||
|
||||
function getShortPath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 2) return path;
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
function getSummary(
|
||||
displayName: string,
|
||||
toolArgs?: Record<string, unknown>
|
||||
): { name: string; detail?: string } {
|
||||
const args = toolArgs || {};
|
||||
const filePath = (args.file_path || args.path || args.file) as string | undefined;
|
||||
const command = args.command as string | undefined;
|
||||
const pattern = (args.pattern || args.query) as string | undefined;
|
||||
|
||||
if (command) {
|
||||
return { name: displayName, detail: truncate(command, 40) };
|
||||
}
|
||||
if (filePath) {
|
||||
return { name: displayName, detail: getShortPath(filePath) };
|
||||
}
|
||||
if (pattern) {
|
||||
return { name: displayName, detail: `"${truncate(pattern, 25)}"` };
|
||||
}
|
||||
return { name: displayName };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function ToolCallTimeline({
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolResult,
|
||||
success,
|
||||
requireApproval = false,
|
||||
approvalStatus,
|
||||
displayData,
|
||||
subAgentProgress,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: ToolCallTimelineProps) {
|
||||
const hasResult = toolResult !== undefined;
|
||||
const isPendingApproval = requireApproval && approvalStatus === 'pending';
|
||||
const isFailed = success === false;
|
||||
const isRejected = approvalStatus === 'rejected';
|
||||
// Tool is processing only if: no result yet, not pending approval, and not marked as failed
|
||||
// The `success === false` check handles incomplete tool calls from history (never got a result)
|
||||
const isProcessing = !hasResult && !isPendingApproval && !isFailed;
|
||||
const hasSubAgentProgress = !!subAgentProgress;
|
||||
|
||||
// Determine if there's meaningful content to show
|
||||
const hasExpandableContent = Boolean(
|
||||
displayData ||
|
||||
toolArgs?.content ||
|
||||
(toolArgs?.old_string && toolArgs?.new_string) ||
|
||||
(toolArgs?.command && hasResult)
|
||||
);
|
||||
|
||||
// Determine if this tool has rich UI that should be shown by default
|
||||
// Rich UI includes: displayData, file content previews, and diff views
|
||||
// Exclude bash commands as they're more variable in visual value
|
||||
const hasRichUI = Boolean(
|
||||
displayData || toolArgs?.content || (toolArgs?.old_string && toolArgs?.new_string)
|
||||
);
|
||||
|
||||
// Smart default: expand for pending approvals and successful tools with rich UI
|
||||
// Failed, rejected, and no-output should always be collapsed
|
||||
const [expanded, setExpanded] = useState(
|
||||
isPendingApproval || (hasRichUI && !isFailed && !isRejected)
|
||||
);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Auto-collapse after approval is resolved, but keep open if tool has rich UI and succeeded
|
||||
useEffect(() => {
|
||||
if (requireApproval && approvalStatus && approvalStatus !== 'pending') {
|
||||
// Collapse if rejected or if no rich UI to show
|
||||
if (isRejected || !hasRichUI) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
}, [requireApproval, approvalStatus, hasRichUI, isRejected]);
|
||||
|
||||
const { displayName, source } = stripToolPrefix(toolName);
|
||||
const summary = getSummary(displayName, toolArgs);
|
||||
|
||||
// For sub-agent progress, format the agent name nicely
|
||||
const subAgentLabel = hasSubAgentProgress
|
||||
? subAgentProgress.agentId
|
||||
.replace(/-agent$/, '')
|
||||
.charAt(0)
|
||||
.toUpperCase() + subAgentProgress.agentId.replace(/-agent$/, '').slice(1)
|
||||
: null;
|
||||
|
||||
// Status icon
|
||||
const StatusIcon = isPendingApproval ? (
|
||||
<div className="relative">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="absolute inset-0 rounded-full bg-amber-500/30 animate-ping" />
|
||||
</div>
|
||||
) : isProcessing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
) : isFailed || isRejected ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
);
|
||||
|
||||
// Header click handler
|
||||
const toggleExpanded = () => {
|
||||
if (hasResult || isPendingApproval || hasExpandableContent) {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const canExpand = hasResult || isPendingApproval || hasExpandableContent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-0.5 rounded-md transition-colors inline-block max-w-full',
|
||||
isPendingApproval &&
|
||||
'bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/30'
|
||||
)}
|
||||
>
|
||||
{/* Collapsed Header - Always Visible */}
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
disabled={!canExpand}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 p-1.5 text-left rounded-md',
|
||||
canExpand && 'hover:bg-muted/40 cursor-pointer',
|
||||
!canExpand && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">{StatusIcon}</div>
|
||||
|
||||
{/* Summary text */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs flex-1 truncate',
|
||||
isPendingApproval && 'text-amber-700 dark:text-amber-300 font-medium',
|
||||
isProcessing && 'text-blue-600 dark:text-blue-400',
|
||||
isFailed && 'text-red-600 dark:text-red-400',
|
||||
isRejected && 'text-red-600 dark:text-red-400',
|
||||
!isPendingApproval &&
|
||||
!isProcessing &&
|
||||
!isFailed &&
|
||||
!isRejected &&
|
||||
'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{isPendingApproval ? 'Approval required: ' : ''}
|
||||
{isFailed ? 'Failed: ' : ''}
|
||||
{isRejected ? 'Rejected: ' : ''}
|
||||
{hasSubAgentProgress ? (
|
||||
<span className="font-mono">
|
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">
|
||||
{subAgentLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">(</span>
|
||||
<span className="text-foreground/80">{subAgentProgress.task}</span>
|
||||
<span className="text-muted-foreground/50">)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{summary.name.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">(</span>
|
||||
{summary.detail && (
|
||||
<span className="text-foreground/80">{summary.detail}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">)</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Badges */}
|
||||
{source && (
|
||||
<span className="text-[10px] text-muted-foreground/60 flex-shrink-0">
|
||||
[{source}]
|
||||
</span>
|
||||
)}
|
||||
{requireApproval && approvalStatus === 'approved' && (
|
||||
<span className="text-[10px] text-green-600 dark:text-green-500 flex-shrink-0">
|
||||
approved
|
||||
</span>
|
||||
)}
|
||||
{isProcessing && !hasSubAgentProgress && (
|
||||
<span className="text-[10px] text-muted-foreground/50 flex-shrink-0">
|
||||
running...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sub-agent progress indicator */}
|
||||
{hasSubAgentProgress && isProcessing && (
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||
{subAgentProgress.toolsCalled} tool
|
||||
{subAgentProgress.toolsCalled !== 1 ? 's' : ''} |{' '}
|
||||
{subAgentProgress.currentTool}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand chevron */}
|
||||
{canExpand && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 text-muted-foreground/40 flex-shrink-0 transition-transform',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="px-1.5 pb-2 pt-1 space-y-2 animate-fade-in">
|
||||
{/* Pending Approval Content */}
|
||||
{isPendingApproval && (
|
||||
<>
|
||||
{renderApprovalPreview()}
|
||||
<div className="flex gap-1.5 flex-wrap pt-1">
|
||||
<Button
|
||||
onClick={() => onApprove?.(undefined, false)}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700 text-white h-6 text-[11px] px-2.5"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove?.(undefined, true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2 text-green-600 border-green-300 hover:bg-green-50 dark:border-green-700 dark:hover:bg-green-950/20"
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Always
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onReject?.()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[11px] px-2.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Content */}
|
||||
{isFailed && hasResult && renderErrorContent()}
|
||||
|
||||
{/* Result Content */}
|
||||
{hasResult && !isFailed && !isPendingApproval && renderResultContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Render Functions
|
||||
// =========================================================================
|
||||
|
||||
function renderApprovalPreview() {
|
||||
const command = toolArgs?.command as string | undefined;
|
||||
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
|
||||
const content = toolArgs?.content as string | undefined;
|
||||
const oldString = toolArgs?.old_string as string | undefined;
|
||||
const newString = toolArgs?.new_string as string | undefined;
|
||||
|
||||
// Bash command
|
||||
if (command) {
|
||||
return (
|
||||
<div className="ml-5 bg-zinc-900 rounded overflow-hidden">
|
||||
<pre className="px-2 py-1.5 text-[11px] text-zinc-300 font-mono whitespace-pre-wrap">
|
||||
<span className="text-zinc-500">$ </span>
|
||||
{command}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit operation - diff view without header (file path is in summary)
|
||||
if (oldString !== undefined && newString !== undefined) {
|
||||
return (
|
||||
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
|
||||
{oldString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`o${i}`}
|
||||
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
>
|
||||
<span className="text-red-500/50 mr-1">-</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{newString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`n${i}`}
|
||||
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
>
|
||||
<span className="text-green-500/50 mr-1">+</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : 'more...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Write/Create file
|
||||
if (content && filePath) {
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderErrorContent() {
|
||||
let errorMessage = 'Unknown error';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
const textPart = result.content.find(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
) as { text?: string } | undefined;
|
||||
if (textPart?.text) errorMessage = textPart.text;
|
||||
} else if (result.error) {
|
||||
errorMessage =
|
||||
typeof result.error === 'string' ? result.error : JSON.stringify(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/50 rounded px-2 py-1.5">
|
||||
<pre className="text-[11px] text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-all">
|
||||
{truncate(errorMessage, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderResultContent() {
|
||||
// Extract toolArgs for checking rich content availability
|
||||
const command = toolArgs?.command as string | undefined;
|
||||
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
|
||||
const content = toolArgs?.content as string | undefined;
|
||||
const oldString = toolArgs?.old_string as string | undefined;
|
||||
const newString = toolArgs?.new_string as string | undefined;
|
||||
|
||||
// Check if we have rich content that should override simple displayData
|
||||
const hasRichContent = !!(
|
||||
content ||
|
||||
(oldString !== undefined && newString !== undefined) ||
|
||||
(command && hasResult)
|
||||
);
|
||||
|
||||
// If we have display metadata from tool, use it (unless we have richer content)
|
||||
if (displayData) {
|
||||
// Skip simple file metadata display if we have rich content to show
|
||||
if (displayData.type === 'file' && hasRichContent) {
|
||||
// Fall through to render rich content below
|
||||
} else {
|
||||
switch (displayData.type) {
|
||||
case 'diff':
|
||||
return renderDiff(displayData);
|
||||
case 'shell':
|
||||
return renderShell(displayData);
|
||||
case 'search':
|
||||
return renderSearch(displayData);
|
||||
case 'file':
|
||||
return renderFile(displayData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render rich content from toolArgs
|
||||
// Bash command with result
|
||||
if (command && hasResult) {
|
||||
return renderBashResult(command);
|
||||
}
|
||||
|
||||
// Edit operation (old_string -> new_string)
|
||||
if (oldString !== undefined && newString !== undefined && filePath) {
|
||||
return renderEditResult(filePath, oldString, newString);
|
||||
}
|
||||
|
||||
// Write/create file
|
||||
if (content && filePath) {
|
||||
return renderWriteResult(filePath, content);
|
||||
}
|
||||
|
||||
// Read file - show content from result
|
||||
if (displayName.toLowerCase().includes('read') && filePath) {
|
||||
return renderReadResult(filePath);
|
||||
}
|
||||
|
||||
// Fallback to generic
|
||||
return renderGenericResult();
|
||||
}
|
||||
|
||||
function renderDiff(data: Extract<ToolDisplayData, { type: 'diff' }>) {
|
||||
const lines = data.unified
|
||||
.split('\n')
|
||||
.filter((l) => !l.startsWith('@@') && !l.startsWith('---') && !l.startsWith('+++'));
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 40 : 8);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<FileEdit className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono text-foreground/70">
|
||||
{getShortPath(data.filename)}
|
||||
</span>
|
||||
<span className="text-green-600">+{data.additions}</span>
|
||||
<span className="text-red-600">-{data.deletions}</span>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
|
||||
{displayLines.map((line, i) => {
|
||||
const isAdd = line.startsWith('+');
|
||||
const isDel = line.startsWith('-');
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-[11px] font-mono',
|
||||
isAdd &&
|
||||
'bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300',
|
||||
isDel &&
|
||||
'bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300',
|
||||
!isAdd && !isDel && 'text-foreground/50'
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 opacity-50">
|
||||
{isAdd ? '+' : isDel ? '-' : ' '}
|
||||
</span>
|
||||
{line.slice(1) || ' '}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{lines.length > 8 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 8} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderShell(data: Extract<ToolDisplayData, { type: 'shell' }>) {
|
||||
const output = data.stdout || data.stderr || '';
|
||||
const lines = output.split('\n');
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
|
||||
const isError = data.exitCode !== 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Terminal className="h-3 w-3 text-muted-foreground" />
|
||||
<code className="font-mono text-foreground/70 truncate flex-1">
|
||||
{truncate(data.command, 50)}
|
||||
</code>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded',
|
||||
isError
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-600'
|
||||
)}
|
||||
>
|
||||
{isError ? `exit ${data.exitCode}` : 'ok'}
|
||||
</span>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(data.command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{output && (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSearch(data: Extract<ToolDisplayData, { type: 'search' }>) {
|
||||
const matches = data.matches.slice(0, detailsExpanded ? 15 : 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Search className="h-3 w-3 text-muted-foreground" />
|
||||
<code className="font-mono text-foreground/70">{data.pattern}</code>
|
||||
<span className="text-muted-foreground">
|
||||
{data.totalMatches} matches{data.truncated && '+'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50 divide-y divide-border/30">
|
||||
{matches.map((m, i) => (
|
||||
<div key={i} className="px-2 py-1 text-[11px]">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-mono">
|
||||
{getShortPath(m.file)}
|
||||
</span>
|
||||
{m.line > 0 && <span className="text-muted-foreground">:{m.line}</span>}
|
||||
{m.content && (
|
||||
<div className="text-foreground/60 font-mono truncate">
|
||||
{m.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{data.matches.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${data.matches.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFile(data: Extract<ToolDisplayData, { type: 'file' }>) {
|
||||
const OpIcon = { read: FileText, write: FileEdit, create: FilePlus, delete: Trash2 }[
|
||||
data.operation
|
||||
];
|
||||
const opColors = {
|
||||
read: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
||||
write: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600',
|
||||
create: 'bg-green-100 dark:bg-green-900/30 text-green-600',
|
||||
delete: 'bg-red-100 dark:bg-red-900/30 text-red-600',
|
||||
}[data.operation];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<OpIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className={cn('px-1 py-0.5 rounded text-[10px]', opColors)}>
|
||||
{data.operation}
|
||||
</span>
|
||||
<span className="font-mono text-foreground/70">{getShortPath(data.path)}</span>
|
||||
{data.lineCount !== undefined && (
|
||||
<span className="text-muted-foreground">{data.lineCount} lines</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Render functions for generating preview from toolArgs (no displayData)
|
||||
// =========================================================================
|
||||
|
||||
function renderBashResult(_command: string) {
|
||||
// Extract and parse bash result from tool result
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode: number | undefined;
|
||||
let duration: number | undefined;
|
||||
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
const textContent = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
|
||||
// Try to parse as JSON bash result
|
||||
try {
|
||||
const parsed = JSON.parse(textContent);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
stdout = parsed.stdout || '';
|
||||
stderr = parsed.stderr || '';
|
||||
exitCode =
|
||||
typeof parsed.exit_code === 'number' ? parsed.exit_code : undefined;
|
||||
duration =
|
||||
typeof parsed.duration === 'number' ? parsed.duration : undefined;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as plain output
|
||||
stdout = textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = stdout || stderr;
|
||||
if (!output && exitCode === undefined) return null;
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
|
||||
const isError = exitCode !== undefined && exitCode !== 0;
|
||||
|
||||
return (
|
||||
<div className="ml-5 space-y-1">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{exitCode !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded font-medium',
|
||||
isError
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
)}
|
||||
>
|
||||
{isError ? `exit ${exitCode}` : 'success'}
|
||||
</span>
|
||||
)}
|
||||
{duration !== undefined && (
|
||||
<span className="text-muted-foreground">{duration}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{output && (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stderr if present and different from stdout */}
|
||||
{stderr && stderr !== stdout && (
|
||||
<div className="bg-red-50 dark:bg-red-950/30 rounded overflow-hidden border border-red-200 dark:border-red-900/30">
|
||||
<div className="px-2 py-0.5 text-[10px] text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/20 border-b border-red-200 dark:border-red-900/30">
|
||||
stderr
|
||||
</div>
|
||||
<pre className="p-1.5 text-[11px] text-red-800 dark:text-red-300 font-mono whitespace-pre-wrap max-h-24 overflow-y-auto">
|
||||
{stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditResult(_filePath: string, oldString: string, newString: string) {
|
||||
return (
|
||||
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
|
||||
{oldString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`o${i}`}
|
||||
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
>
|
||||
<span className="text-red-500/50 mr-1">-</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{newString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`n${i}`}
|
||||
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
>
|
||||
<span className="text-green-500/50 mr-1">+</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : 'more...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWriteResult(filePath: string, content: string) {
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderReadResult(filePath: string) {
|
||||
// Extract content from tool result
|
||||
let content = '';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
content = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGenericResult() {
|
||||
// Extract text from result
|
||||
let resultText = '';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
resultText = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultText) return null;
|
||||
|
||||
const lines = resultText.split('\n');
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 20 : 5);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
|
||||
<pre className="p-1.5 text-[11px] text-foreground/70 font-mono whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
116
dexto/packages/webui/components/ToolConfirmationHandler.tsx
Normal file
116
dexto/packages/webui/components/ToolConfirmationHandler.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useSubmitApproval } from './hooks/useApprovals';
|
||||
import type { ApprovalRequest } from '@dexto/core';
|
||||
import { ApprovalStatus } from '@dexto/core';
|
||||
import { useSessionStore } from '@/lib/stores/sessionStore';
|
||||
import { useApprovalStore } from '@/lib/stores/approvalStore';
|
||||
|
||||
// Re-export ApprovalRequest as ApprovalEvent for consumers (e.g., InlineApprovalCard, MessageList)
|
||||
export type ApprovalEvent = ApprovalRequest;
|
||||
|
||||
interface ToolConfirmationHandlerProps {
|
||||
onApprovalRequest?: (approval: ApprovalEvent | null) => void;
|
||||
onApprove?: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onDeny?: () => void;
|
||||
onHandlersReady?: (handlers: ApprovalHandlers) => void;
|
||||
}
|
||||
|
||||
export interface ApprovalHandlers {
|
||||
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebUI component for handling approval requests
|
||||
* Uses approvalStore for state management (no DOM events)
|
||||
* Sends responses back through API via useSubmitApproval
|
||||
*/
|
||||
export function ToolConfirmationHandler({
|
||||
onApprovalRequest,
|
||||
onApprove: externalOnApprove,
|
||||
onDeny: externalOnDeny,
|
||||
onHandlersReady,
|
||||
}: ToolConfirmationHandlerProps) {
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const pendingApproval = useApprovalStore((s) => s.pendingApproval);
|
||||
const clearApproval = useApprovalStore((s) => s.clearApproval);
|
||||
const { mutateAsync: submitApproval } = useSubmitApproval();
|
||||
|
||||
// Filter approvals by current session
|
||||
const currentApproval =
|
||||
pendingApproval &&
|
||||
(!pendingApproval.sessionId ||
|
||||
!currentSessionId ||
|
||||
pendingApproval.sessionId === currentSessionId)
|
||||
? pendingApproval
|
||||
: null;
|
||||
|
||||
// Notify parent component when approval state changes
|
||||
useEffect(() => {
|
||||
onApprovalRequest?.(currentApproval);
|
||||
}, [currentApproval, onApprovalRequest]);
|
||||
|
||||
// Send confirmation response via API
|
||||
const sendResponse = useCallback(
|
||||
async (approved: boolean, formData?: Record<string, unknown>, rememberChoice?: boolean) => {
|
||||
if (!currentApproval) return;
|
||||
|
||||
const { approvalId } = currentApproval;
|
||||
|
||||
console.debug(
|
||||
`[WebUI] Sending approval response for ${approvalId}: ${approved ? 'approved' : 'denied'}`
|
||||
);
|
||||
|
||||
// Use approval's sessionId as authoritative source for cache invalidation
|
||||
const sessionId = currentApproval.sessionId || currentSessionId;
|
||||
if (!sessionId) {
|
||||
console.error('[WebUI] Cannot submit approval without sessionId');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await submitApproval({
|
||||
approvalId,
|
||||
sessionId,
|
||||
status: approved ? ApprovalStatus.APPROVED : ApprovalStatus.DENIED,
|
||||
...(approved && formData ? { formData } : {}),
|
||||
...(approved && rememberChoice !== undefined ? { rememberChoice } : {}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[WebUI] Failed to send approval response: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear current approval (processes queue automatically)
|
||||
clearApproval();
|
||||
},
|
||||
[currentApproval, currentSessionId, submitApproval, clearApproval]
|
||||
);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(formData?: Record<string, unknown>, rememberChoice?: boolean) => {
|
||||
sendResponse(true, formData, rememberChoice);
|
||||
externalOnApprove?.(formData, rememberChoice);
|
||||
},
|
||||
[sendResponse, externalOnApprove]
|
||||
);
|
||||
|
||||
const handleDeny = useCallback(() => {
|
||||
sendResponse(false);
|
||||
externalOnDeny?.();
|
||||
}, [sendResponse, externalOnDeny]);
|
||||
|
||||
// Expose handlers to parent via callback
|
||||
useEffect(() => {
|
||||
if (onHandlersReady) {
|
||||
onHandlersReady({
|
||||
onApprove: handleApprove,
|
||||
onDeny: handleDeny,
|
||||
});
|
||||
}
|
||||
}, [handleApprove, handleDeny, onHandlersReady]);
|
||||
|
||||
// Don't render anything - the approval will be rendered in MessageList
|
||||
return null;
|
||||
}
|
||||
71
dexto/packages/webui/components/hooks/ApprovalContext.tsx
Normal file
71
dexto/packages/webui/components/hooks/ApprovalContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Approval Context
|
||||
*
|
||||
* Provides approval handling functionality via React Context.
|
||||
* Wraps the approvalStore to provide a clean API for components.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useCallback, type ReactNode } from 'react';
|
||||
import { useApprovalStore } from '@/lib/stores/approvalStore';
|
||||
import type { ApprovalRequest } from '@dexto/core';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalContextType {
|
||||
/**
|
||||
* Handle an incoming approval request (add to store)
|
||||
*/
|
||||
handleApprovalRequest: (request: ApprovalRequest) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const ApprovalContext = createContext<ApprovalContextType | null>(null);
|
||||
|
||||
// =============================================================================
|
||||
// Provider
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ApprovalProvider({ children }: ApprovalProviderProps) {
|
||||
const addApproval = useApprovalStore((s) => s.addApproval);
|
||||
|
||||
const handleApprovalRequest = useCallback(
|
||||
(request: ApprovalRequest) => {
|
||||
addApproval(request);
|
||||
},
|
||||
[addApproval]
|
||||
);
|
||||
|
||||
return (
|
||||
<ApprovalContext.Provider value={{ handleApprovalRequest }}>
|
||||
{children}
|
||||
</ApprovalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to access approval handling functions
|
||||
*
|
||||
* @throws Error if used outside ApprovalProvider
|
||||
*/
|
||||
export function useApproval(): ApprovalContextType {
|
||||
const context = useContext(ApprovalContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useApproval must be used within an ApprovalProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useChat, Message, UIUserMessage, UIAssistantMessage, UIToolMessage } from './useChat';
|
||||
import { useApproval } from './ApprovalContext';
|
||||
import { usePendingApprovals } from './useApprovals';
|
||||
import type { FilePart, ImagePart, TextPart, UIResourcePart } from '../../types';
|
||||
import type { SanitizedToolResult, ApprovalRequest } from '@dexto/core';
|
||||
import { getResourceKind } from '@dexto/core';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
useAgentStore,
|
||||
useSessionStore,
|
||||
useChatStore,
|
||||
useCurrentSessionId,
|
||||
useIsWelcomeState,
|
||||
useSessionMessages,
|
||||
} from '@/lib/stores/index.js';
|
||||
|
||||
// Helper to get history endpoint type (workaround for string literal path)
|
||||
type HistoryEndpoint = (typeof client.api.sessions)[':sessionId']['history'];
|
||||
|
||||
// Derive history message type from Hono client response (server schema is source of truth)
|
||||
type HistoryResponse = Awaited<ReturnType<HistoryEndpoint['$get']>>;
|
||||
type HistoryData = Awaited<ReturnType<Extract<HistoryResponse, { ok: true }>['json']>>;
|
||||
type HistoryMessage = HistoryData['history'][number];
|
||||
|
||||
// Derive toolCall type from HistoryMessage
|
||||
type ToolCall = NonNullable<HistoryMessage['toolCalls']>[number];
|
||||
|
||||
interface ChatContextType {
|
||||
messages: Message[];
|
||||
sendMessage: (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => void;
|
||||
reset: () => void;
|
||||
switchSession: (sessionId: string) => void;
|
||||
loadSessionHistory: (sessionId: string) => Promise<void>;
|
||||
returnToWelcome: () => void;
|
||||
cancel: (sessionId?: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to fetch and convert session history to UI messages
|
||||
async function fetchSessionHistory(
|
||||
sessionId: string
|
||||
): Promise<{ messages: Message[]; isBusy: boolean }> {
|
||||
const response = await client.api.sessions[':sessionId'].history.$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch session history');
|
||||
}
|
||||
const data = await response.json();
|
||||
const history = data.history || [];
|
||||
return {
|
||||
messages: convertHistoryToMessages(history, sessionId),
|
||||
isBusy: data.isBusy ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to convert session history API response to UI messages
|
||||
function convertHistoryToMessages(history: HistoryMessage[], sessionId: string): Message[] {
|
||||
const uiMessages: Message[] = [];
|
||||
const pendingToolCalls = new Map<string, number>();
|
||||
|
||||
for (let index = 0; index < history.length; index++) {
|
||||
const msg = history[index];
|
||||
const createdAt = msg.timestamp ?? Date.now() - (history.length - index) * 1000;
|
||||
const baseId = `session-${sessionId}-${index}`;
|
||||
|
||||
// Skip system messages - they're not shown in UI
|
||||
if (msg.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deriveResources = (
|
||||
content: Array<TextPart | ImagePart | FilePart | UIResourcePart>
|
||||
): SanitizedToolResult['resources'] => {
|
||||
const resources: NonNullable<SanitizedToolResult['resources']> = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (
|
||||
part.type === 'image' &&
|
||||
typeof part.image === 'string' &&
|
||||
part.image.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.image.substring(1);
|
||||
resources.push({
|
||||
uri,
|
||||
kind: 'image',
|
||||
mimeType: part.mimeType ?? 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === 'file' &&
|
||||
typeof part.data === 'string' &&
|
||||
part.data.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.data.substring(1);
|
||||
const mimeType = part.mimeType ?? 'application/octet-stream';
|
||||
const kind = getResourceKind(mimeType);
|
||||
|
||||
resources.push({
|
||||
uri,
|
||||
kind,
|
||||
mimeType,
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resources.length > 0 ? resources : undefined;
|
||||
};
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
// Create assistant message
|
||||
if (msg.content) {
|
||||
// Extract text content from string or ContentPart array
|
||||
let textContent: string | null = null;
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Extract text from ContentPart array
|
||||
const textParts = msg.content
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text);
|
||||
textContent = textParts.length > 0 ? textParts.join('\n') : null;
|
||||
}
|
||||
|
||||
const assistantMessage: UIAssistantMessage = {
|
||||
id: baseId,
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
createdAt,
|
||||
sessionId,
|
||||
tokenUsage: msg.tokenUsage,
|
||||
reasoning: msg.reasoning,
|
||||
model: msg.model,
|
||||
provider: msg.provider,
|
||||
};
|
||||
uiMessages.push(assistantMessage);
|
||||
}
|
||||
|
||||
// Create tool messages for tool calls
|
||||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
msg.toolCalls.forEach((toolCall: ToolCall, toolIndex: number) => {
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (toolCall?.function) {
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to parse toolCall arguments for ${toolCall.function?.name || 'unknown'}: ${e}`
|
||||
);
|
||||
toolArgs = {};
|
||||
}
|
||||
}
|
||||
const toolName = toolCall.function?.name || 'unknown';
|
||||
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: `${baseId}-tool-${toolIndex}`,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt: createdAt + toolIndex,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolCallId: toolCall.id,
|
||||
};
|
||||
|
||||
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
|
||||
pendingToolCalls.set(toolCall.id, uiMessages.length);
|
||||
}
|
||||
|
||||
uiMessages.push(toolMessage);
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
|
||||
const toolName = typeof msg.name === 'string' ? msg.name : 'unknown';
|
||||
const normalizedContent: Array<TextPart | ImagePart | FilePart> = Array.isArray(
|
||||
msg.content
|
||||
)
|
||||
? (msg.content as Array<TextPart | ImagePart | FilePart>)
|
||||
: typeof msg.content === 'string'
|
||||
? [{ type: 'text', text: msg.content }]
|
||||
: [];
|
||||
|
||||
const inferredResources = deriveResources(normalizedContent);
|
||||
// Extract success status from stored message (defaults to true for backwards compatibility)
|
||||
const success =
|
||||
'success' in msg && typeof msg.success === 'boolean' ? msg.success : true;
|
||||
const sanitizedFromHistory: SanitizedToolResult = {
|
||||
content: normalizedContent,
|
||||
...(inferredResources ? { resources: inferredResources } : {}),
|
||||
meta: {
|
||||
toolName,
|
||||
toolCallId: toolCallId ?? `tool-${index}`,
|
||||
success,
|
||||
},
|
||||
};
|
||||
|
||||
// Extract approval metadata if present (type-safe with optional chaining)
|
||||
const requireApproval: boolean | undefined =
|
||||
'requireApproval' in msg && typeof msg.requireApproval === 'boolean'
|
||||
? msg.requireApproval
|
||||
: undefined;
|
||||
const approvalStatus: 'pending' | 'approved' | 'rejected' | undefined =
|
||||
'approvalStatus' in msg &&
|
||||
(msg.approvalStatus === 'pending' ||
|
||||
msg.approvalStatus === 'approved' ||
|
||||
msg.approvalStatus === 'rejected')
|
||||
? msg.approvalStatus
|
||||
: undefined;
|
||||
|
||||
if (toolCallId && pendingToolCalls.has(toolCallId)) {
|
||||
// Update existing tool message with result
|
||||
const messageIndex = pendingToolCalls.get(toolCallId)!;
|
||||
const existingMessage = uiMessages[messageIndex] as UIToolMessage;
|
||||
uiMessages[messageIndex] = {
|
||||
...existingMessage,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
} else {
|
||||
// Create new tool message with result
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: baseId,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolCallId,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
uiMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// User message (only remaining case after system/assistant/tool handled)
|
||||
if (msg.role === 'user') {
|
||||
const userMessage: UIUserMessage = {
|
||||
id: baseId,
|
||||
role: 'user',
|
||||
content: msg.content,
|
||||
createdAt,
|
||||
sessionId,
|
||||
};
|
||||
uiMessages.push(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any tool calls that never received results as failed (incomplete)
|
||||
// This happens when a run was interrupted or crashed before tool completion
|
||||
for (const [_callId, messageIndex] of pendingToolCalls) {
|
||||
const msg = uiMessages[messageIndex];
|
||||
if (msg && msg.role === 'tool' && msg.toolResult === undefined) {
|
||||
uiMessages[messageIndex] = {
|
||||
...msg,
|
||||
toolResultSuccess: false, // Mark as failed so UI doesn't show "running"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return uiMessages;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get state from stores using centralized selectors
|
||||
const currentSessionId = useCurrentSessionId();
|
||||
const isWelcomeState = useIsWelcomeState();
|
||||
|
||||
// Local state for UI flow control only (not shared/persisted state)
|
||||
const [isSwitchingSession, setIsSwitchingSession] = useState(false); // Guard against rapid session switches
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false); // Guard against double auto-creation
|
||||
const lastSwitchedSessionRef = useRef<string | null>(null); // Track last switched session to prevent duplicate switches
|
||||
const newSessionWithMessageRef = useRef<string | null>(null); // Track new sessions that already have first message sent
|
||||
const currentSessionIdRef = useRef<string | null>(null); // Synchronous session ID (updates before React state to prevent race conditions)
|
||||
|
||||
// Session abort controllers for cancellation
|
||||
const sessionAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// useChat hook manages abort controllers internally
|
||||
const {
|
||||
sendMessage: originalSendMessage,
|
||||
reset: originalReset,
|
||||
cancel,
|
||||
} = useChat(currentSessionIdRef, sessionAbortControllersRef);
|
||||
|
||||
// Restore pending approvals when session changes (e.g., after page refresh)
|
||||
const { handleApprovalRequest } = useApproval();
|
||||
const { data: pendingApprovalsData } = usePendingApprovals(currentSessionId);
|
||||
const restoredApprovalsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingApprovalsData?.approvals || pendingApprovalsData.approvals.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore any pending approvals that haven't been restored yet
|
||||
for (const approval of pendingApprovalsData.approvals) {
|
||||
// Skip if we've already restored this approval
|
||||
if (restoredApprovalsRef.current.has(approval.approvalId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as restored before calling handler to prevent duplicates
|
||||
restoredApprovalsRef.current.add(approval.approvalId);
|
||||
|
||||
// Convert API response format to ApprovalRequest format
|
||||
// TODO: The API returns a simplified format without full metadata because
|
||||
// ApprovalCoordinator only tracks approval IDs, not the full request data.
|
||||
// To fix properly: store full ApprovalRequest in ApprovalCoordinator when
|
||||
// requests are created, then return that data from GET /api/approvals.
|
||||
handleApprovalRequest({
|
||||
approvalId: approval.approvalId,
|
||||
type: approval.type,
|
||||
sessionId: approval.sessionId,
|
||||
timeout: approval.timeout,
|
||||
timestamp: new Date(approval.timestamp),
|
||||
metadata: approval.metadata,
|
||||
} as ApprovalRequest);
|
||||
}
|
||||
}, [pendingApprovalsData, handleApprovalRequest]);
|
||||
|
||||
// Clear restored approvals tracking when session changes
|
||||
useEffect(() => {
|
||||
restoredApprovalsRef.current.clear();
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Messages from centralized selector (stable reference, handles null session)
|
||||
const messages = useSessionMessages(currentSessionId);
|
||||
|
||||
// Mutation for generating session title
|
||||
const { mutate: generateTitle } = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await client.api.sessions[':sessionId']['generate-title'].$post({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate title');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.title;
|
||||
},
|
||||
onSuccess: (_title, sessionId) => {
|
||||
// Invalidate sessions query to show the new title
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
// Also invalidate the specific session query if needed
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(sessionId) });
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-create session on first message with random UUID
|
||||
const createAutoSession = useCallback(async (): Promise<string> => {
|
||||
const response = await client.api.sessions.$post({
|
||||
json: {}, // Let server generate random UUID
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.session?.id) {
|
||||
throw new Error('Session ID not found in server response');
|
||||
}
|
||||
|
||||
const sessionId = data.session.id;
|
||||
|
||||
// Track session creation
|
||||
analytics.trackSessionCreated({
|
||||
sessionId,
|
||||
trigger: 'first_message',
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}, [analytics]);
|
||||
|
||||
// Enhanced sendMessage with auto-session creation
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => {
|
||||
let sessionId = currentSessionId;
|
||||
let isNewSession = false;
|
||||
|
||||
// Auto-create session on first message and wait for it to complete
|
||||
if (!sessionId && isWelcomeState) {
|
||||
if (isCreatingSession) return; // Another send in-flight; drop duplicate request
|
||||
try {
|
||||
setIsCreatingSession(true);
|
||||
sessionId = await createAutoSession();
|
||||
isNewSession = true;
|
||||
|
||||
// Mark this session as a new session before navigation
|
||||
// This allows switchSession to run but skip history load
|
||||
newSessionWithMessageRef.current = sessionId;
|
||||
|
||||
// Update ref BEFORE streaming to prevent race conditions
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Send message BEFORE navigating
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
|
||||
// Navigate - this will trigger switchSession via ChatApp useEffect
|
||||
navigate({ to: `/chat/${sessionId}`, replace: true });
|
||||
|
||||
// Generate title for newly created session after first message
|
||||
// Use setTimeout to delay title generation until message is complete
|
||||
setTimeout(() => {
|
||||
if (sessionId) generateTitle(sessionId);
|
||||
}, 0);
|
||||
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
return; // Don't send message if session creation fails
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send if we're using an existing session (not a newly created one)
|
||||
if (sessionId && !isNewSession) {
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
}
|
||||
|
||||
// Track message sent
|
||||
if (sessionId) {
|
||||
analytics.trackMessageSent({
|
||||
sessionId,
|
||||
provider: 'unknown', // Provider/model tracking moved to component level
|
||||
model: 'unknown',
|
||||
hasImage: !!imageData,
|
||||
hasFile: !!fileData,
|
||||
messageLength: content.length,
|
||||
});
|
||||
} else {
|
||||
console.error('No session available for sending message');
|
||||
}
|
||||
},
|
||||
[
|
||||
originalSendMessage,
|
||||
currentSessionId,
|
||||
isWelcomeState,
|
||||
isCreatingSession,
|
||||
createAutoSession,
|
||||
navigate,
|
||||
analytics,
|
||||
generateTitle,
|
||||
]
|
||||
);
|
||||
|
||||
// Enhanced reset with session support
|
||||
const reset = useCallback(() => {
|
||||
if (currentSessionId) {
|
||||
// Track conversation reset
|
||||
const messageCount = messages.filter((m) => m.sessionId === currentSessionId).length;
|
||||
analytics.trackSessionReset({
|
||||
sessionId: currentSessionId,
|
||||
messageCount,
|
||||
});
|
||||
|
||||
originalReset(currentSessionId);
|
||||
}
|
||||
}, [originalReset, currentSessionId, analytics, messages]);
|
||||
|
||||
// Load session history when switching sessions
|
||||
const { data: sessionHistoryData } = useQuery({
|
||||
queryKey: queryKeys.sessions.history(currentSessionId || ''),
|
||||
queryFn: async () => {
|
||||
if (!currentSessionId) {
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
try {
|
||||
return await fetchSessionHistory(currentSessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
enabled: false, // Manual refetch only
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Sync session history data to messages when it changes
|
||||
useEffect(() => {
|
||||
if (sessionHistoryData && currentSessionId) {
|
||||
const currentMessages = useChatStore.getState().getMessages(currentSessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === currentSessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
useChatStore
|
||||
.getState()
|
||||
.setMessages(currentSessionId, sessionHistoryData.messages as any);
|
||||
}
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
if (sessionHistoryData.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
client.api.sessions[':sessionId'].cancel
|
||||
.$post({
|
||||
param: { sessionId: currentSessionId },
|
||||
json: { clearQueue: true },
|
||||
})
|
||||
.catch((e) => console.warn('Failed to cancel busy session:', e));
|
||||
}
|
||||
}
|
||||
}, [sessionHistoryData, currentSessionId]);
|
||||
|
||||
const loadSessionHistory = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const result = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.sessions.history(sessionId),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchSessionHistory(sessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const currentMessages = useChatStore.getState().getMessages(sessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === sessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
// Populate chatStore with history (cast to compatible type)
|
||||
useChatStore.getState().initFromHistory(sessionId, result.messages as any);
|
||||
}
|
||||
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
// This ensures clean state - user can see history and send new messages
|
||||
// TODO: Implement stream reconnection instead of cancelling
|
||||
// - Add GET /sessions/{sessionId}/events SSE endpoint for listen-only mode
|
||||
// - Connect to event stream when isBusy=true to resume receiving updates
|
||||
if (result.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
try {
|
||||
await client.api.sessions[':sessionId'].cancel.$post({
|
||||
param: { sessionId },
|
||||
json: { clearQueue: true }, // Hard cancel - clear queue too
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to cancel busy session on load:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading session history:', error);
|
||||
useChatStore.getState().clearMessages(sessionId);
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
// Switch to a different session and load it on the backend
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
// Guard against switching to same session or rapid successive switches
|
||||
// Use ref for immediate check (state updates are async)
|
||||
if (
|
||||
sessionId === currentSessionId ||
|
||||
sessionId === lastSwitchedSessionRef.current ||
|
||||
isSwitchingSession
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitchingSession(true);
|
||||
try {
|
||||
// Track session switch (defensive - analytics failures shouldn't block switching)
|
||||
try {
|
||||
analytics.trackSessionSwitched({
|
||||
fromSessionId: currentSessionId,
|
||||
toSessionId: sessionId,
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
console.error('Failed to track session switch:', analyticsError);
|
||||
}
|
||||
|
||||
// Skip history load for newly created sessions with first message already sent
|
||||
// This prevents replacing message IDs and breaking error anchoring
|
||||
// TODO: Long-term fix - backend should generate and persist message IDs
|
||||
// so history reload doesn't cause ID mismatches
|
||||
const skipHistoryLoad = newSessionWithMessageRef.current === sessionId;
|
||||
if (skipHistoryLoad) {
|
||||
// Clear the ref after using it once
|
||||
newSessionWithMessageRef.current = null;
|
||||
}
|
||||
|
||||
// Update ref BEFORE store to prevent race conditions with streaming
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().setCurrentSession(sessionId);
|
||||
|
||||
// Mark this session as being switched to after state update succeeds
|
||||
lastSwitchedSessionRef.current = sessionId;
|
||||
|
||||
if (!skipHistoryLoad) {
|
||||
await loadSessionHistory(sessionId);
|
||||
}
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes via useQuery
|
||||
} catch (error) {
|
||||
console.error('Error switching session:', error);
|
||||
throw error; // Re-throw so UI can handle the error
|
||||
} finally {
|
||||
// Always reset the switching flag, even if error occurs
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
},
|
||||
[currentSessionId, isSwitchingSession, loadSessionHistory, analytics]
|
||||
);
|
||||
|
||||
// Return to welcome state (no active session)
|
||||
const returnToWelcome = useCallback(() => {
|
||||
currentSessionIdRef.current = null;
|
||||
lastSwitchedSessionRef.current = null; // Clear to allow switching to same session again
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().returnToWelcome();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
sendMessage,
|
||||
reset,
|
||||
switchSession,
|
||||
loadSessionHistory,
|
||||
returnToWelcome,
|
||||
cancel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextType {
|
||||
const context = useContext(ChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useChatContext must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
54
dexto/packages/webui/components/hooks/useAgentConfig.ts
Normal file
54
dexto/packages/webui/components/hooks/useAgentConfig.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
// Fetch agent configuration
|
||||
export function useAgentConfig(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agent.config,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agent.config.$get();
|
||||
return await response.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Validate agent configuration
|
||||
export function useValidateAgent() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ yaml }: { yaml: string }) => {
|
||||
const response = await client.api.agent.validate.$post({
|
||||
json: { yaml },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type ValidationError = NonNullable<
|
||||
ReturnType<typeof useValidateAgent>['data']
|
||||
>['errors'][number];
|
||||
export type ValidationWarning = NonNullable<
|
||||
ReturnType<typeof useValidateAgent>['data']
|
||||
>['warnings'][number];
|
||||
|
||||
// Save agent configuration
|
||||
export function useSaveAgentConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ yaml }: { yaml: string }) => {
|
||||
const response = await client.api.agent.config.$post({
|
||||
json: { yaml },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate agent config to refresh after save
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config });
|
||||
},
|
||||
});
|
||||
}
|
||||
115
dexto/packages/webui/components/hooks/useAgents.ts
Normal file
115
dexto/packages/webui/components/hooks/useAgents.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agents.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agents.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - agent list rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentPath() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agents.path,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agent.path.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agent path: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - current agent path only changes on explicit switch
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.switch.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.switch.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to switch agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.install.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.install.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to install agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.uninstall.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.uninstall.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to uninstall agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.custom.create.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.custom.create.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type CreateAgentPayload = Parameters<
|
||||
typeof client.api.agents.custom.create.$post
|
||||
>[0]['json'];
|
||||
62
dexto/packages/webui/components/hooks/useApprovals.ts
Normal file
62
dexto/packages/webui/components/hooks/useApprovals.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
type ApprovalPayload = Parameters<(typeof client.api.approvals)[':approvalId']['$post']>[0]['json'];
|
||||
|
||||
export function useSubmitApproval() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: { approvalId: string; sessionId: string } & ApprovalPayload
|
||||
) => {
|
||||
const { approvalId, sessionId: _sessionId, ...body } = payload;
|
||||
const response = await client.api.approvals[':approvalId'].$post({
|
||||
param: { approvalId },
|
||||
json: body,
|
||||
header: {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to submit approval: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate pending approvals cache when an approval is submitted
|
||||
// Query is keyed by sessionId, not approvalId
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.approvals.pending(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch pending approvals for a session.
|
||||
* Use this to restore approval UI state after page refresh.
|
||||
*
|
||||
* @param sessionId - The session ID to fetch pending approvals for
|
||||
* @param options.enabled - Whether to enable the query (default: true if sessionId provided)
|
||||
*/
|
||||
export function usePendingApprovals(sessionId: string | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.approvals.pending(sessionId || ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return { approvals: [] };
|
||||
const response = await client.api.approvals.$get({
|
||||
query: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pending approvals');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for consumers
|
||||
export type PendingApproval = NonNullable<
|
||||
ReturnType<typeof usePendingApprovals>['data']
|
||||
>['approvals'][number];
|
||||
405
dexto/packages/webui/components/hooks/useChat.ts
Normal file
405
dexto/packages/webui/components/hooks/useChat.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import type { SanitizedToolResult } from '@dexto/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { createMessageStream } from '@dexto/client-sdk';
|
||||
import type { MessageStreamEvent } from '@dexto/client-sdk';
|
||||
import { eventBus } from '@/lib/events/EventBus.js';
|
||||
import { useChatStore } from '@/lib/stores/chatStore.js';
|
||||
import type { Session } from './useSessions.js';
|
||||
|
||||
// Content part types - import from centralized types.ts
|
||||
import type { FileData } from '../../types.js';
|
||||
|
||||
// Tool result types
|
||||
export interface ToolResultError {
|
||||
error: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ToolResultContent = SanitizedToolResult;
|
||||
|
||||
export type ToolResult = ToolResultError | SanitizedToolResult | string | Record<string, unknown>;
|
||||
|
||||
// Type guards for tool results
|
||||
export function isToolResultError(result: unknown): result is ToolResultError {
|
||||
return typeof result === 'object' && result !== null && 'error' in result;
|
||||
}
|
||||
|
||||
export function isToolResultContent(result: unknown): result is ToolResultContent {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'content' in result &&
|
||||
Array.isArray((result as ToolResultContent).content)
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Re-export Message types from chatStore (single source of truth)
|
||||
// =============================================================================
|
||||
|
||||
// Import from chatStore
|
||||
import type { Message } from '@/lib/stores/chatStore.js';
|
||||
|
||||
// Re-export for API compatibility - components can import these from either place
|
||||
export type { Message, ErrorMessage } from '@/lib/stores/chatStore.js';
|
||||
|
||||
// Legacy type aliases for code that uses the discriminated union pattern
|
||||
// These are intersection types that narrow the Message type by role
|
||||
export type UIUserMessage = Message & { role: 'user' };
|
||||
export type UIAssistantMessage = Message & { role: 'assistant' };
|
||||
export type UIToolMessage = Message & { role: 'tool' };
|
||||
|
||||
// =============================================================================
|
||||
// Message Type Guards
|
||||
// =============================================================================
|
||||
|
||||
/** Type guard for user messages */
|
||||
export function isUserMessage(msg: Message): msg is UIUserMessage {
|
||||
return msg.role === 'user';
|
||||
}
|
||||
|
||||
/** Type guard for assistant messages */
|
||||
export function isAssistantMessage(msg: Message): msg is UIAssistantMessage {
|
||||
return msg.role === 'assistant';
|
||||
}
|
||||
|
||||
/** Type guard for tool messages */
|
||||
export function isToolMessage(msg: Message): msg is UIToolMessage {
|
||||
return msg.role === 'tool';
|
||||
}
|
||||
|
||||
export type StreamStatus = 'idle' | 'connecting' | 'open' | 'closed';
|
||||
|
||||
const generateUniqueId = () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
export function useChat(
|
||||
activeSessionIdRef: React.MutableRefObject<string | null>,
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>
|
||||
) {
|
||||
const analytics = useAnalytics();
|
||||
const analyticsRef = useRef(analytics);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Helper to update sessions cache (replaces DOM events)
|
||||
const updateSessionActivity = useCallback(
|
||||
(sessionId: string, incrementMessageCount: boolean = true) => {
|
||||
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) => {
|
||||
const exists = old.some((s) => s.id === sessionId);
|
||||
if (exists) {
|
||||
return old.map((session) =>
|
||||
session.id === sessionId
|
||||
? {
|
||||
...session,
|
||||
...(incrementMessageCount && {
|
||||
messageCount: session.messageCount + 1,
|
||||
}),
|
||||
lastActivity: Date.now(),
|
||||
}
|
||||
: session
|
||||
);
|
||||
} else {
|
||||
// Create new session entry
|
||||
const newSession: Session = {
|
||||
id: sessionId,
|
||||
createdAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
messageCount: 1,
|
||||
title: null,
|
||||
};
|
||||
return [newSession, ...old];
|
||||
}
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const updateSessionTitle = useCallback(
|
||||
(sessionId: string, title: string) => {
|
||||
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) =>
|
||||
old.map((session) => (session.id === sessionId ? { ...session, title } : session))
|
||||
);
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
// Track message IDs for error anchoring
|
||||
const lastUserMessageIdRef = useRef<string | null>(null);
|
||||
const lastMessageIdRef = useRef<string | null>(null);
|
||||
// Map callId to message index for O(1) tool result pairing
|
||||
const pendingToolCallsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Keep analytics ref updated
|
||||
useEffect(() => {
|
||||
analyticsRef.current = analytics;
|
||||
}, [analytics]);
|
||||
|
||||
// Abort controller management (moved from ChatContext)
|
||||
const getAbortController = useCallback(
|
||||
(sessionId: string): AbortController => {
|
||||
const existing = abortControllersRef.current.get(sessionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, controller);
|
||||
return controller;
|
||||
},
|
||||
[abortControllersRef]
|
||||
);
|
||||
|
||||
const abortSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
}
|
||||
},
|
||||
[abortControllersRef]
|
||||
);
|
||||
|
||||
const isForActiveSession = useCallback(
|
||||
(sessionId?: string): boolean => {
|
||||
if (!sessionId) return false;
|
||||
const current = activeSessionIdRef.current;
|
||||
return !!current && sessionId === current;
|
||||
},
|
||||
[activeSessionIdRef]
|
||||
);
|
||||
|
||||
const processEvent = useCallback(
|
||||
(event: MessageStreamEvent) => {
|
||||
// All streaming events must have sessionId
|
||||
if (!event.sessionId) {
|
||||
console.error(`Event missing sessionId: ${JSON.stringify(event)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session match
|
||||
if (!isForActiveSession(event.sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch to EventBus - handlers.ts will update chatStore
|
||||
// NOTE: All store updates (messages, streaming, processing) are handled by handlers.ts
|
||||
// This function only handles React-specific side effects not in handlers.ts:
|
||||
// - TanStack Query cache updates
|
||||
// - Analytics tracking
|
||||
// - Ref updates for error anchoring
|
||||
eventBus.dispatch(event);
|
||||
|
||||
// Handle React-specific side effects not in handlers.ts
|
||||
// IMPORTANT: Do NOT update chatStore here - that's handled by handlers.ts via EventBus
|
||||
switch (event.name) {
|
||||
case 'llm:response': {
|
||||
// Update sessions cache (response received)
|
||||
updateSessionActivity(event.sessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'llm:tool-call': {
|
||||
const { toolName, sessionId } = event;
|
||||
|
||||
// Track tool called analytics
|
||||
if (toolName) {
|
||||
analyticsRef.current.trackToolCalled({
|
||||
toolName,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'llm:tool-result': {
|
||||
const { toolName, success } = event;
|
||||
|
||||
// Track tool result analytics
|
||||
if (toolName) {
|
||||
analyticsRef.current.trackToolResult({
|
||||
toolName,
|
||||
success: success !== false,
|
||||
sessionId: event.sessionId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session:title-updated': {
|
||||
// Update TanStack Query cache
|
||||
updateSessionTitle(event.sessionId, event.title);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message:dequeued': {
|
||||
// Update sessions cache
|
||||
updateSessionActivity(event.sessionId);
|
||||
|
||||
// Invalidate queue cache so UI removes the message
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(event.sessionId),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[isForActiveSession, analyticsRef, updateSessionActivity, updateSessionTitle, queryClient]
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: FileData,
|
||||
sessionId?: string
|
||||
) => {
|
||||
if (!sessionId) {
|
||||
console.error('Session ID required for sending message');
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort previous request if any
|
||||
abortSession(sessionId);
|
||||
|
||||
const abortController = getAbortController(sessionId) || new AbortController();
|
||||
|
||||
useChatStore.getState().setProcessing(sessionId, true);
|
||||
|
||||
// Add user message to state
|
||||
const userId = generateUniqueId();
|
||||
lastUserMessageIdRef.current = userId;
|
||||
lastMessageIdRef.current = userId; // Track for error anchoring
|
||||
useChatStore.getState().addMessage(sessionId, {
|
||||
id: userId,
|
||||
role: 'user',
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
imageData,
|
||||
fileData,
|
||||
});
|
||||
|
||||
// Update sessions cache (user message sent)
|
||||
updateSessionActivity(sessionId);
|
||||
|
||||
try {
|
||||
// Build content parts array from text, image, and file data
|
||||
// New API uses unified ContentInput = string | ContentPart[]
|
||||
const contentParts: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; image: string; mimeType?: string }
|
||||
| { type: 'file'; data: string; mimeType: string; filename?: string }
|
||||
> = [];
|
||||
|
||||
if (content) {
|
||||
contentParts.push({ type: 'text', text: content });
|
||||
}
|
||||
if (imageData) {
|
||||
contentParts.push({
|
||||
type: 'image',
|
||||
image: imageData.image,
|
||||
mimeType: imageData.mimeType,
|
||||
});
|
||||
}
|
||||
if (fileData) {
|
||||
contentParts.push({
|
||||
type: 'file',
|
||||
data: fileData.data,
|
||||
mimeType: fileData.mimeType,
|
||||
filename: fileData.filename,
|
||||
});
|
||||
}
|
||||
|
||||
// Always use SSE for all events (tool calls, approvals, responses)
|
||||
// The 'stream' flag only controls whether chunks update UI incrementally
|
||||
const responsePromise = client.api['message-stream'].$post({
|
||||
json: {
|
||||
content:
|
||||
contentParts.length === 1 && contentParts[0]?.type === 'text'
|
||||
? content // Simple text-only case: send as string
|
||||
: contentParts, // Multimodal: send as array
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
const iterator = createMessageStream(responsePromise, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
for await (const event of iterator) {
|
||||
processEvent(event);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Handle abort gracefully
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('Stream aborted by user');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Stream error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
useChatStore.getState().setProcessing(sessionId, false);
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Failed to send message';
|
||||
useChatStore.getState().setError(sessionId, {
|
||||
id: generateUniqueId(),
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
context: 'stream',
|
||||
recoverable: true,
|
||||
sessionId,
|
||||
anchorMessageId: lastMessageIdRef.current || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[processEvent, abortSession, getAbortController, updateSessionActivity]
|
||||
);
|
||||
|
||||
const reset = useCallback(async (sessionId?: string) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
await client.api.reset.$post({
|
||||
json: { sessionId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Failed to reset session: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// Note: Messages are now in chatStore, not local state
|
||||
useChatStore.getState().setError(sessionId, null);
|
||||
lastUserMessageIdRef.current = null;
|
||||
lastMessageIdRef.current = null;
|
||||
pendingToolCallsRef.current.clear();
|
||||
useChatStore.getState().setProcessing(sessionId, false);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(async (sessionId?: string, clearQueue: boolean = false) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Tell server to cancel the LLM stream
|
||||
// Soft cancel (default): Only cancel current response, queued messages continue
|
||||
// Hard cancel (clearQueue=true): Cancel current response AND clear all queued messages
|
||||
try {
|
||||
await client.api.sessions[':sessionId'].cancel.$post({
|
||||
param: { sessionId },
|
||||
json: { clearQueue },
|
||||
});
|
||||
} catch (err) {
|
||||
// Server cancel is best-effort - log but don't throw
|
||||
console.warn('Failed to cancel server-side:', err);
|
||||
}
|
||||
|
||||
// UI state will be updated when server sends run:complete event
|
||||
pendingToolCallsRef.current.clear();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
reset,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
37
dexto/packages/webui/components/hooks/useCurrentLLM.ts
Normal file
37
dexto/packages/webui/components/hooks/useCurrentLLM.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Hook to fetch the current LLM configuration for a session
|
||||
* Centralized access point for currentLLM data
|
||||
*/
|
||||
export function useCurrentLLM(sessionId: string | null, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.llm.current(sessionId),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm.current.$get({
|
||||
query: sessionId ? { sessionId } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch current LLM config');
|
||||
}
|
||||
const data = await response.json();
|
||||
const cfg = data.config || data;
|
||||
return {
|
||||
provider: cfg.provider,
|
||||
model: cfg.model,
|
||||
displayName: cfg.displayName,
|
||||
baseURL: cfg.baseURL,
|
||||
viaDexto: data.routing?.viaDexto ?? false,
|
||||
};
|
||||
},
|
||||
// Always enabled - API returns default config when no sessionId
|
||||
// This ensures the model name shows on welcome screen
|
||||
enabled,
|
||||
retry: false, // Don't retry on error - UI can still operate
|
||||
});
|
||||
}
|
||||
|
||||
// Export type for components to use
|
||||
export type CurrentLLM = NonNullable<ReturnType<typeof useCurrentLLM>['data']>;
|
||||
23
dexto/packages/webui/components/hooks/useDextoAuth.ts
Normal file
23
dexto/packages/webui/components/hooks/useDextoAuth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch Dexto authentication status.
|
||||
* Returns whether dexto auth is enabled, user is authenticated, and can use dexto provider.
|
||||
*/
|
||||
export function useDextoAuth(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dextoAuth.status,
|
||||
queryFn: async () => {
|
||||
const res = await client.api['dexto-auth'].status.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch dexto auth status');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - auth status may change
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type DextoAuthStatus = NonNullable<ReturnType<typeof useDextoAuth>['data']>;
|
||||
25
dexto/packages/webui/components/hooks/useDiscovery.ts
Normal file
25
dexto/packages/webui/components/hooks/useDiscovery.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch available providers and capabilities.
|
||||
* Returns blob storage providers, compression strategies, custom tool providers, and internal tools.
|
||||
*/
|
||||
export function useDiscovery(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.discovery.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.discovery.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch discovery data');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - providers don't change often
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type DiscoveryResponse = NonNullable<ReturnType<typeof useDiscovery>['data']>;
|
||||
export type DiscoveredProvider = DiscoveryResponse['blob'][number];
|
||||
export type InternalToolInfo = DiscoveryResponse['internalTools'][number];
|
||||
29
dexto/packages/webui/components/hooks/useFontsReady.ts
Normal file
29
dexto/packages/webui/components/hooks/useFontsReady.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Returns true when the browser reports that page fonts are loaded.
|
||||
// This lets us defer first-measure actions (like autosize) until
|
||||
// typographic metrics are stable to avoid initial reflow.
|
||||
export function useFontsReady(): boolean {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// If Font Loading API is unavailable, assume ready to avoid blocking.
|
||||
const anyDoc = document as any;
|
||||
if (!anyDoc.fonts || !anyDoc.fonts.ready) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
anyDoc.fonts.ready.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
}
|
||||
28
dexto/packages/webui/components/hooks/useGreeting.ts
Normal file
28
dexto/packages/webui/components/hooks/useGreeting.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
async function fetchGreeting(sessionId?: string | null): Promise<string | null> {
|
||||
const data = await client.api.greeting.$get({
|
||||
query: sessionId ? { sessionId } : {},
|
||||
});
|
||||
if (!data.ok) {
|
||||
throw new Error(`Failed to fetch greeting: ${data.status}`);
|
||||
}
|
||||
const json = await data.json();
|
||||
return json.greeting ?? null;
|
||||
}
|
||||
|
||||
export function useGreeting(sessionId?: string | null) {
|
||||
const {
|
||||
data: greeting = null,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.greeting(sessionId),
|
||||
queryFn: () => fetchGreeting(sessionId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - greeting is static per agent
|
||||
});
|
||||
|
||||
return { greeting, isLoading, error: error?.message ?? null };
|
||||
}
|
||||
187
dexto/packages/webui/components/hooks/useInputHistory.ts
Normal file
187
dexto/packages/webui/components/hooks/useInputHistory.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { isTextPart } from '../../types';
|
||||
|
||||
const MAX_HISTORY_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Hook to fetch user messages from session history
|
||||
*/
|
||||
function useSessionUserMessages(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessions.history(sessionId ?? ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return [];
|
||||
const response = await client.api.sessions[':sessionId'].history.$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
const historyMessages = data.history || [];
|
||||
|
||||
// Extract text content from user messages
|
||||
const userTexts: string[] = [];
|
||||
for (const msg of historyMessages) {
|
||||
if (msg.role !== 'user') continue;
|
||||
if (!msg.content || !Array.isArray(msg.content)) continue;
|
||||
|
||||
const textParts = msg.content
|
||||
.filter(isTextPart)
|
||||
.map((part) => part.text.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (textParts.length > 0) {
|
||||
userTexts.push(textParts.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate consecutive entries
|
||||
const deduplicated: string[] = [];
|
||||
for (const text of userTexts) {
|
||||
if (deduplicated.length === 0 || deduplicated[deduplicated.length - 1] !== text) {
|
||||
deduplicated.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated.slice(-MAX_HISTORY_SIZE);
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
staleTime: 30000, // Consider fresh for 30s
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing input history with shell-style navigation.
|
||||
*
|
||||
* - Up arrow: Navigate to older entries
|
||||
* - Down arrow: Navigate to newer entries
|
||||
* - History cursor resets when user types new input
|
||||
* - Loads previous user messages from session history via TanStack Query
|
||||
*
|
||||
*/
|
||||
export function useInputHistory(sessionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch historical user messages from session
|
||||
const { data: history = [] } = useSessionUserMessages(sessionId);
|
||||
|
||||
// Current position in history (-1 means not browsing, 0 = oldest, length-1 = newest)
|
||||
const [cursor, setCursor] = useState<number>(-1);
|
||||
// Track the text that was in input before browsing started
|
||||
const savedInputRef = useRef<string>('');
|
||||
// Track last recalled text to prevent hijacking normal editing
|
||||
const lastRecalledRef = useRef<string | null>(null);
|
||||
|
||||
// Reset cursor when session changes
|
||||
useEffect(() => {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
savedInputRef.current = '';
|
||||
}, [sessionId]);
|
||||
|
||||
/**
|
||||
* Invalidate history cache after sending a message.
|
||||
* Call this after successfully sending to refresh the history.
|
||||
*/
|
||||
const invalidateHistory = useCallback(() => {
|
||||
if (sessionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sessions.history(sessionId),
|
||||
});
|
||||
}
|
||||
}, [queryClient, sessionId]);
|
||||
|
||||
/**
|
||||
* Check if we should handle navigation (up/down) vs normal cursor movement.
|
||||
* Only handle navigation when:
|
||||
* 1. Input is empty, OR
|
||||
* 2. Cursor is at position 0 AND text matches last recalled history
|
||||
*/
|
||||
const shouldHandleNavigation = useCallback(
|
||||
(currentText: string, cursorPosition: number): boolean => {
|
||||
if (!currentText) return true;
|
||||
if (cursorPosition === 0 && lastRecalledRef.current === currentText) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigate up (older entries)
|
||||
* Returns the text to display, or null if at end of history
|
||||
*/
|
||||
const navigateUp = useCallback(
|
||||
(currentText: string): string | null => {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
// If not currently browsing, save current input and start from newest
|
||||
if (cursor === -1) {
|
||||
savedInputRef.current = currentText;
|
||||
const idx = history.length - 1;
|
||||
setCursor(idx);
|
||||
const text = history[idx];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
}
|
||||
|
||||
// Already at oldest entry
|
||||
if (cursor === 0) return null;
|
||||
|
||||
// Move to older entry
|
||||
const newCursor = cursor - 1;
|
||||
setCursor(newCursor);
|
||||
const text = history[newCursor];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
},
|
||||
[history, cursor]
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigate down (newer entries)
|
||||
* Returns the text to display, or null if back to current input
|
||||
*/
|
||||
const navigateDown = useCallback((): string | null => {
|
||||
if (cursor === -1) return null;
|
||||
|
||||
// At newest entry - return to saved input
|
||||
if (cursor === history.length - 1) {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
return savedInputRef.current;
|
||||
}
|
||||
|
||||
// Move to newer entry
|
||||
const newCursor = cursor + 1;
|
||||
setCursor(newCursor);
|
||||
const text = history[newCursor];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
}, [history, cursor]);
|
||||
|
||||
/**
|
||||
* Reset history browsing (call when user types)
|
||||
*/
|
||||
const resetCursor = useCallback(() => {
|
||||
if (cursor !== -1) {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
return {
|
||||
history,
|
||||
cursor,
|
||||
invalidateHistory,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
resetCursor,
|
||||
shouldHandleNavigation,
|
||||
isBrowsing: cursor !== -1,
|
||||
};
|
||||
}
|
||||
165
dexto/packages/webui/components/hooks/useLLM.ts
Normal file
165
dexto/packages/webui/components/hooks/useLLM.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useLLMCatalog(options?: { enabled?: boolean; mode?: 'grouped' | 'flat' }) {
|
||||
const mode = options?.mode ?? 'grouped';
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, mode],
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm.catalog.$get({ query: { mode } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch LLM catalog: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - catalog rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchLLM() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: SwitchLLMPayload) => {
|
||||
const response = await client.api.llm.switch.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to switch LLM: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate catalog and all current LLM queries to refresh all views
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm', 'current'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderApiKey(provider: LLMProvider | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, 'key', provider],
|
||||
queryFn: async () => {
|
||||
if (!provider) return null;
|
||||
const response = await client.api.llm.key[':provider'].$get({
|
||||
param: { provider },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API key: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!provider,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: SaveApiKeyPayload) => {
|
||||
const response = await client.api.llm.key.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save API key: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
|
||||
// Also invalidate the specific provider key query
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...queryKeys.llm.catalog, 'key', variables.provider],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Custom models hooks
|
||||
export function useCustomModels(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.llm.customModels,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm['custom-models'].$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch custom models: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.models;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CustomModelPayload) => {
|
||||
const response = await client.api.llm['custom-models'].$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create custom model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
// URL-encode the name to handle OpenRouter model IDs with slashes (e.g., anthropic/claude-3.5-sonnet)
|
||||
const encodedName = encodeURIComponent(name);
|
||||
const response = await client.api.llm['custom-models'][':name'].$delete({
|
||||
param: { name: encodedName },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete custom model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Model capabilities hook - resolves gateway providers to underlying model capabilities
|
||||
export function useModelCapabilities(
|
||||
provider: LLMProvider | null | undefined,
|
||||
model: string | null | undefined,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, 'capabilities', provider, model],
|
||||
queryFn: async () => {
|
||||
if (!provider || !model) return null;
|
||||
const response = await client.api.llm.capabilities.$get({
|
||||
query: { provider, model },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model capabilities: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!provider && !!model,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - capabilities rarely change
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type SaveApiKeyPayload = Parameters<typeof client.api.llm.key.$post>[0]['json'];
|
||||
export type LLMProvider = SaveApiKeyPayload['provider'];
|
||||
export type SwitchLLMPayload = Parameters<typeof client.api.llm.switch.$post>[0]['json'];
|
||||
|
||||
// Helper to extract the custom-models endpoint (Prettier can't parse hyphenated bracket notation in Parameters<>)
|
||||
type CustomModelsEndpoint = (typeof client.api.llm)['custom-models'];
|
||||
export type CustomModelPayload = Parameters<CustomModelsEndpoint['$post']>[0]['json'];
|
||||
export type CustomModel = NonNullable<ReturnType<typeof useCustomModels>['data']>[number];
|
||||
56
dexto/packages/webui/components/hooks/useMemories.ts
Normal file
56
dexto/packages/webui/components/hooks/useMemories.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useMemories(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.memories.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.memory.$get({ query: {} });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch memories: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.memories;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - memories can be added during chat
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ memoryId }: { memoryId: string }) => {
|
||||
const response = await client.api.memory[':id'].$delete({ param: { id: memoryId } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete memory: ${response.status}`);
|
||||
}
|
||||
return memoryId;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Parameters<typeof client.api.memory.$post>[0]['json']) => {
|
||||
const response = await client.api.memory.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create memory: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Memory = NonNullable<ReturnType<typeof useMemories>['data']>[number];
|
||||
114
dexto/packages/webui/components/hooks/useModels.ts
Normal file
114
dexto/packages/webui/components/hooks/useModels.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Hooks for local GGUF and Ollama model management.
|
||||
*
|
||||
* These hooks expose model discovery that was previously only available in CLI.
|
||||
* Used by the model picker to display installed local models and Ollama models.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Fetch installed local GGUF models from state.json.
|
||||
* These are models downloaded via CLI or manually registered.
|
||||
*/
|
||||
export function useLocalModels(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models.local,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.models.local.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch local models: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30 * 1000, // 30 seconds - models don't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available Ollama models from the Ollama server.
|
||||
* Returns empty list with available=false if Ollama is not running.
|
||||
*/
|
||||
export function useOllamaModels(options?: { enabled?: boolean; baseURL?: string }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models.ollama(options?.baseURL),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.models.ollama.$get({
|
||||
query: options?.baseURL ? { baseURL: options.baseURL } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: false, // Don't retry if Ollama not running
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a local GGUF file path.
|
||||
* Used by the custom model form to validate file paths before saving.
|
||||
*/
|
||||
export function useValidateLocalFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (filePath: string) => {
|
||||
const response = await client.api.models.local.validate.$post({
|
||||
json: { filePath },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to validate file: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an installed local model.
|
||||
* Removes from state.json and optionally deletes the GGUF file from disk.
|
||||
*/
|
||||
export function useDeleteInstalledModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
modelId,
|
||||
deleteFile = true,
|
||||
}: {
|
||||
modelId: string;
|
||||
deleteFile?: boolean;
|
||||
}) => {
|
||||
const response = await client.api.models.local[':modelId'].$delete({
|
||||
param: { modelId },
|
||||
json: { deleteFile },
|
||||
});
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Failed to delete model: ${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data.error) errorMessage = data.error;
|
||||
} catch {
|
||||
// Response body not JSON-parseable (e.g., network error, proxy error), use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate local models cache to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.models.local });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type LocalModel = NonNullable<ReturnType<typeof useLocalModels>['data']>['models'][number];
|
||||
export type OllamaModel = NonNullable<ReturnType<typeof useOllamaModels>['data']>['models'][number];
|
||||
export type ValidateLocalFileResult = Awaited<
|
||||
ReturnType<ReturnType<typeof useValidateLocalFile>['mutateAsync']>
|
||||
>;
|
||||
27
dexto/packages/webui/components/hooks/useOpenRouter.ts
Normal file
27
dexto/packages/webui/components/hooks/useOpenRouter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Validate an OpenRouter model ID against the registry.
|
||||
* Returns validation result with status and optional error.
|
||||
*/
|
||||
export function useValidateOpenRouterModel() {
|
||||
return useMutation({
|
||||
mutationFn: async (modelId: string) => {
|
||||
// URL-encode the model ID to handle slashes (e.g., anthropic/claude-3.5-sonnet)
|
||||
const encodedModelId = encodeURIComponent(modelId);
|
||||
const response = await client.api.openrouter.validate[':modelId'].$get({
|
||||
param: { modelId: encodedModelId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to validate model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types
|
||||
export type ValidateOpenRouterModelResult = Awaited<
|
||||
ReturnType<ReturnType<typeof useValidateOpenRouterModel>['mutateAsync']>
|
||||
>;
|
||||
69
dexto/packages/webui/components/hooks/usePrompts.ts
Normal file
69
dexto/packages/webui/components/hooks/usePrompts.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client.js';
|
||||
|
||||
/**
|
||||
* Hook for fetching prompts with TanStack Query caching
|
||||
*
|
||||
* Replaces the old promptCache.ts in-memory cache with proper
|
||||
* persistent caching that survives page refreshes.
|
||||
*/
|
||||
export function usePrompts(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.prompts.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.prompts.$get();
|
||||
const data = await response.json();
|
||||
return data.prompts;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
|
||||
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.prompts.custom.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.prompts.custom.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create prompt: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ResolvePromptParams = Parameters<(typeof client.api.prompts)[':name']['resolve']['$get']>[0];
|
||||
|
||||
export function useResolvePrompt() {
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: {
|
||||
name: string;
|
||||
} & ResolvePromptParams['query']
|
||||
) => {
|
||||
const { name, ...query } = payload;
|
||||
const response = await client.api.prompts[':name'].resolve.$get({
|
||||
param: { name: encodeURIComponent(name) },
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve prompt: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Prompt = NonNullable<ReturnType<typeof usePrompts>['data']>[number];
|
||||
145
dexto/packages/webui/components/hooks/useQueue.ts
Normal file
145
dexto/packages/webui/components/hooks/useQueue.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch queued messages for a session
|
||||
*/
|
||||
export function useQueuedMessages(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.queue.list(sessionId ?? ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return { messages: [], count: 0 };
|
||||
const response = await client.api.queue[':sessionId'].$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch queued messages');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
// Refetch frequently while processing to show queue updates
|
||||
refetchInterval: (query) => ((query.state.data?.count ?? 0) > 0 ? 2000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
// Export type for queued message
|
||||
export type QueuedMessage = NonNullable<
|
||||
ReturnType<typeof useQueuedMessages>['data']
|
||||
>['messages'][number];
|
||||
|
||||
/**
|
||||
* Hook to queue a new message
|
||||
*/
|
||||
export function useQueueMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
sessionId,
|
||||
message,
|
||||
imageData,
|
||||
fileData,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message?: string;
|
||||
imageData?: { image: string; mimeType: string };
|
||||
fileData?: { data: string; mimeType: string; filename?: string };
|
||||
}) => {
|
||||
// Build content parts array from text, image, and file data
|
||||
// New API uses unified ContentInput = string | ContentPart[]
|
||||
const contentParts: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; image: string; mimeType?: string }
|
||||
| { type: 'file'; data: string; mimeType: string; filename?: string }
|
||||
> = [];
|
||||
|
||||
if (message) {
|
||||
contentParts.push({ type: 'text', text: message });
|
||||
}
|
||||
if (imageData) {
|
||||
contentParts.push({
|
||||
type: 'image',
|
||||
image: imageData.image,
|
||||
mimeType: imageData.mimeType,
|
||||
});
|
||||
}
|
||||
if (fileData) {
|
||||
contentParts.push({
|
||||
type: 'file',
|
||||
data: fileData.data,
|
||||
mimeType: fileData.mimeType,
|
||||
filename: fileData.filename,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await client.api.queue[':sessionId'].$post({
|
||||
param: { sessionId },
|
||||
json: {
|
||||
content:
|
||||
contentParts.length === 1 && contentParts[0]?.type === 'text'
|
||||
? message! // Simple text-only case: send as string
|
||||
: contentParts, // Multimodal: send as array
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to queue message');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to remove a single queued message
|
||||
*/
|
||||
export function useRemoveQueuedMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId, messageId }: { sessionId: string; messageId: string }) => {
|
||||
const response = await client.api.queue[':sessionId'][':messageId'].$delete({
|
||||
param: { sessionId, messageId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove queued message');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to clear all queued messages for a session
|
||||
*/
|
||||
export function useClearQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await client.api.queue[':sessionId'].$delete({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to clear queue');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, sessionId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
203
dexto/packages/webui/components/hooks/useResourceContent.ts
Normal file
203
dexto/packages/webui/components/hooks/useResourceContent.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
type NormalizedResourceItem =
|
||||
| {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'image';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
alt?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'audio';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
filename?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'video';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
filename?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'file';
|
||||
src?: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export interface NormalizedResource {
|
||||
uri: string;
|
||||
name?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
items: NormalizedResourceItem[];
|
||||
}
|
||||
|
||||
export interface ResourceState {
|
||||
status: 'loading' | 'loaded' | 'error';
|
||||
data?: NormalizedResource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type ResourceStateMap = Record<string, ResourceState>;
|
||||
|
||||
function buildDataUrl(base64: string, mimeType: string): string {
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
function normalizeResource(uri: string, payload: any): NormalizedResource {
|
||||
const contents = Array.isArray(payload?.contents) ? payload.contents : [];
|
||||
const meta = (payload?._meta ?? {}) as Record<string, unknown>;
|
||||
const name =
|
||||
(typeof meta.originalName === 'string' && meta.originalName.trim().length > 0
|
||||
? meta.originalName
|
||||
: undefined) || uri;
|
||||
|
||||
const items: NormalizedResourceItem[] = [];
|
||||
|
||||
for (const item of contents) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
|
||||
if (typeof (item as { text?: unknown }).text === 'string') {
|
||||
items.push({
|
||||
kind: 'text',
|
||||
text: (item as { text: string }).text,
|
||||
mimeType: typeof item.mimeType === 'string' ? item.mimeType : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blobData = typeof item.blob === 'string' ? item.blob : undefined;
|
||||
const rawData = typeof item.data === 'string' ? item.data : undefined;
|
||||
const mimeType = typeof item.mimeType === 'string' ? item.mimeType : undefined;
|
||||
const filename = typeof item.filename === 'string' ? item.filename : undefined;
|
||||
|
||||
if ((blobData || rawData) && mimeType) {
|
||||
const base64 = blobData ?? rawData!;
|
||||
const src = buildDataUrl(base64, mimeType);
|
||||
if (mimeType.startsWith('image/')) {
|
||||
items.push({
|
||||
kind: 'image',
|
||||
src,
|
||||
mimeType,
|
||||
alt: filename || name,
|
||||
});
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
items.push({
|
||||
kind: 'audio',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
items.push({
|
||||
kind: 'video',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'file',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mimeType && mimeType.startsWith('text/') && typeof item.value === 'string') {
|
||||
items.push({
|
||||
kind: 'text',
|
||||
text: item.value,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uri,
|
||||
name,
|
||||
meta,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchResourceContent(uri: string): Promise<NormalizedResource> {
|
||||
const response = await client.api.resources[':resourceId'].content.$get({
|
||||
param: { resourceId: encodeURIComponent(uri) },
|
||||
});
|
||||
const body = await response.json();
|
||||
const contentPayload = body?.content;
|
||||
if (!contentPayload) {
|
||||
throw new Error('No content returned for resource');
|
||||
}
|
||||
return normalizeResource(uri, contentPayload);
|
||||
}
|
||||
|
||||
export function useResourceContent(resourceUris: string[]): ResourceStateMap {
|
||||
// Serialize array for stable dependency comparison.
|
||||
// Arrays are compared by reference in React, so ['a','b'] !== ['a','b'] even though
|
||||
// values are identical. Serializing to 'a|b' allows value-based comparison to avoid
|
||||
// unnecessary re-computation when parent passes new array reference with same contents.
|
||||
const serializedUris = resourceUris.join('|');
|
||||
|
||||
const normalizedUris = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const uri of resourceUris) {
|
||||
if (!uri || typeof uri !== 'string') continue;
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
}
|
||||
return ordered;
|
||||
// We use resourceUris inside but only depend on serializedUris. This is safe because
|
||||
// serializedUris is derived from resourceUris - when the string changes, the array
|
||||
// values changed too. This is an intentional optimization to prevent re-runs when
|
||||
// array reference changes but values remain the same.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serializedUris]);
|
||||
|
||||
const queries = useQueries({
|
||||
queries: normalizedUris.map((uri) => ({
|
||||
queryKey: ['resourceContent', uri],
|
||||
queryFn: () => fetchResourceContent(uri),
|
||||
enabled: !!uri,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const resources: ResourceStateMap = useMemo(() => {
|
||||
const result: ResourceStateMap = {};
|
||||
queries.forEach((query, index) => {
|
||||
const uri = normalizedUris[index];
|
||||
if (!uri) return;
|
||||
|
||||
if (query.isLoading) {
|
||||
result[uri] = { status: 'loading' };
|
||||
} else if (query.error) {
|
||||
result[uri] = {
|
||||
status: 'error',
|
||||
error: query.error instanceof Error ? query.error.message : String(query.error),
|
||||
};
|
||||
} else if (query.data) {
|
||||
result[uri] = { status: 'loaded', data: query.data };
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [queries, normalizedUris]);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
export type { NormalizedResourceItem };
|
||||
34
dexto/packages/webui/components/hooks/useResources.ts
Normal file
34
dexto/packages/webui/components/hooks/useResources.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
async function fetchResources() {
|
||||
const response = await client.api.resources.$get();
|
||||
const data = await response.json();
|
||||
if (!data.ok || !Array.isArray(data.resources)) {
|
||||
throw new Error('Invalid response shape');
|
||||
}
|
||||
return data.resources;
|
||||
}
|
||||
|
||||
export function useResources() {
|
||||
const {
|
||||
data: resources = [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch: refresh,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.resources.all,
|
||||
queryFn: fetchResources,
|
||||
staleTime: 60 * 1000, // 1 minute - resources can change when servers connect/disconnect
|
||||
});
|
||||
|
||||
return {
|
||||
resources,
|
||||
loading,
|
||||
error: error?.message ?? null,
|
||||
refresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
50
dexto/packages/webui/components/hooks/useSearch.ts
Normal file
50
dexto/packages/webui/components/hooks/useSearch.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
// Search messages
|
||||
export function useSearchMessages(
|
||||
query: string,
|
||||
sessionId?: string,
|
||||
limit: number = 50,
|
||||
enabled: boolean = true
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.search.messages(query, sessionId, limit),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.search.messages.$get({
|
||||
query: {
|
||||
q: query,
|
||||
limit: limit,
|
||||
...(sessionId && { sessionId }),
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Search sessions
|
||||
export function useSearchSessions(query: string, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.search.sessions(query),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.search.sessions.$get({
|
||||
query: { q: query },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type SearchResult = NonNullable<
|
||||
ReturnType<typeof useSearchMessages>['data']
|
||||
>['results'][number];
|
||||
export type SessionSearchResult = NonNullable<
|
||||
ReturnType<typeof useSearchSessions>['data']
|
||||
>['results'][number];
|
||||
74
dexto/packages/webui/components/hooks/useServerRegistry.ts
Normal file
74
dexto/packages/webui/components/hooks/useServerRegistry.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { serverRegistry } from '@/lib/serverRegistry';
|
||||
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
interface UseServerRegistryOptions {
|
||||
autoLoad?: boolean;
|
||||
initialFilter?: ServerRegistryFilter;
|
||||
}
|
||||
|
||||
export function useServerRegistry(options: UseServerRegistryOptions = {}) {
|
||||
const { autoLoad = true, initialFilter } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [filter, setFilter] = useState<ServerRegistryFilter>(initialFilter || {});
|
||||
|
||||
const {
|
||||
data: entries = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.serverRegistry(filter),
|
||||
queryFn: () => serverRegistry.getEntries(filter),
|
||||
enabled: autoLoad,
|
||||
});
|
||||
|
||||
const markAsInstalledMutation = useMutation({
|
||||
mutationFn: async (entryId: string) => {
|
||||
await serverRegistry.setInstalled(entryId, true);
|
||||
return entryId;
|
||||
},
|
||||
onSuccess: (entryId) => {
|
||||
// Optimistically update the cache
|
||||
queryClient.setQueryData<ServerRegistryEntry[]>(
|
||||
queryKeys.serverRegistry(filter),
|
||||
(old) =>
|
||||
old?.map((entry) =>
|
||||
entry.id === entryId ? { ...entry, isInstalled: true } : entry
|
||||
) ?? []
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const updateFilter = (newFilter: ServerRegistryFilter) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
const loadEntries = async (newFilter?: ServerRegistryFilter) => {
|
||||
if (newFilter) {
|
||||
setFilter(newFilter);
|
||||
} else {
|
||||
// Trigger a refetch with current filter
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.serverRegistry(filter) });
|
||||
}
|
||||
};
|
||||
|
||||
const markAsInstalled = async (entryId: string) => {
|
||||
await markAsInstalledMutation.mutateAsync(entryId);
|
||||
};
|
||||
|
||||
return {
|
||||
entries,
|
||||
isLoading,
|
||||
error: error?.message ?? null,
|
||||
filter,
|
||||
loadEntries,
|
||||
updateFilter,
|
||||
markAsInstalled,
|
||||
clearError: () => {
|
||||
// Errors are automatically cleared when query succeeds
|
||||
},
|
||||
};
|
||||
}
|
||||
109
dexto/packages/webui/components/hooks/useServers.ts
Normal file
109
dexto/packages/webui/components/hooks/useServers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useServers(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.servers.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.mcp.servers.$get();
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
const data = await res.json();
|
||||
// Type is inferred from Hono client response schema
|
||||
return data.servers;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - server status can change
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerTools(serverId: string | null, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.servers.tools(serverId || ''),
|
||||
queryFn: async () => {
|
||||
if (!serverId) return [];
|
||||
const res = await client.api.mcp.servers[':serverId'].tools.$get({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch tools');
|
||||
}
|
||||
const data = await res.json();
|
||||
// Type is inferred from Hono client response schema
|
||||
return data.tools;
|
||||
},
|
||||
enabled: enabled && !!serverId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes - tools don't change once server is connected
|
||||
});
|
||||
}
|
||||
|
||||
// Add new MCP server
|
||||
export function useAddServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Parameters<typeof client.api.mcp.servers.$post>[0]['json']) => {
|
||||
const res = await client.api.mcp.servers.$post({ json: payload });
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || 'Failed to add server');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete MCP server
|
||||
export function useDeleteServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string) => {
|
||||
const res = await client.api.mcp.servers[':serverId'].$delete({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to delete server');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Restart MCP server
|
||||
export function useRestartServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string) => {
|
||||
const res = await client.api.mcp.servers[':serverId'].restart.$post({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to restart server');
|
||||
}
|
||||
return serverId;
|
||||
},
|
||||
onSuccess: (serverId) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
// Invalidate tools for this server as they may have changed after restart
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.tools(serverId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type McpServer = NonNullable<ReturnType<typeof useServers>['data']>[number];
|
||||
export type McpTool = NonNullable<ReturnType<typeof useServerTools>['data']>[number];
|
||||
85
dexto/packages/webui/components/hooks/useSessions.ts
Normal file
85
dexto/packages/webui/components/hooks/useSessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
export function useSessions(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessions.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.sessions.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.sessions;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - sessions can be created frequently
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
export function useCreateSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId }: { sessionId?: string }) => {
|
||||
const response = await client.api.sessions.$post({
|
||||
json: { sessionId: sessionId?.trim() || undefined },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.session;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete a session
|
||||
export function useDeleteSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId }: { sessionId: string }) => {
|
||||
const response = await client.api.sessions[':sessionId'].$delete({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete session: ${response.status}`);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate sessions list to refresh after deletion
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Rename a session (update title)
|
||||
export function useRenameSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId, title }: { sessionId: string; title: string }) => {
|
||||
const response = await client.api.sessions[':sessionId'].$patch({
|
||||
param: { sessionId },
|
||||
json: { title },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to rename session');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.session;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Session = NonNullable<ReturnType<typeof useSessions>['data']>[number];
|
||||
33
dexto/packages/webui/components/hooks/useTheme.ts
Normal file
33
dexto/packages/webui/components/hooks/useTheme.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useTheme() {
|
||||
// Initialize from SSR-provided class on <html> to avoid flicker
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
// Match SSR default from layout (dark)
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
// Sync DOM class, localStorage and cookie when theme changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
const isSecure =
|
||||
typeof window !== 'undefined' && window.location?.protocol === 'https:';
|
||||
document.cookie = `theme=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax${isSecure ? '; Secure' : ''}`;
|
||||
} catch {
|
||||
// Ignore storage errors in restrictive environments
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = (checked: boolean) => {
|
||||
setTheme(checked ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// Keep API shape backward-compatible
|
||||
return { theme, toggleTheme, hasMounted: true } as const;
|
||||
}
|
||||
19
dexto/packages/webui/components/hooks/useTools.ts
Normal file
19
dexto/packages/webui/components/hooks/useTools.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useAllTools(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tools.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.tools.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch tools');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type AllToolsResponse = NonNullable<ReturnType<typeof useAllTools>['data']>;
|
||||
export type ToolInfo = AllToolsResponse['tools'][number];
|
||||
199
dexto/packages/webui/components/providers/EventBusProvider.tsx
Normal file
199
dexto/packages/webui/components/providers/EventBusProvider.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Event Bus Provider
|
||||
*
|
||||
* Provides the event bus context to the React component tree.
|
||||
* Initializes middleware and provides access to the singleton event bus.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
ClientEventBus,
|
||||
eventBus,
|
||||
loggingMiddleware,
|
||||
activityMiddleware,
|
||||
notificationMiddleware,
|
||||
setupEventHandlers,
|
||||
type EventMiddleware,
|
||||
} from '@/lib/events';
|
||||
|
||||
/**
|
||||
* Event bus context value
|
||||
*/
|
||||
interface EventBusContextValue {
|
||||
/** The event bus instance */
|
||||
bus: ClientEventBus;
|
||||
}
|
||||
|
||||
const EventBusContext = createContext<EventBusContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for EventBusProvider
|
||||
*/
|
||||
interface EventBusProviderProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Additional middleware to register (beyond default logging)
|
||||
* Middleware is executed in array order
|
||||
*/
|
||||
middleware?: EventMiddleware[];
|
||||
/**
|
||||
* Enable logging middleware (default: true in development)
|
||||
*/
|
||||
enableLogging?: boolean;
|
||||
/**
|
||||
* Enable activity tracking middleware (default: true)
|
||||
*/
|
||||
enableActivityLogging?: boolean;
|
||||
/**
|
||||
* Enable notification middleware (default: true)
|
||||
*/
|
||||
enableNotifications?: boolean;
|
||||
/**
|
||||
* Custom event bus instance (for testing)
|
||||
* If not provided, uses the singleton instance
|
||||
*/
|
||||
bus?: ClientEventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Bus Provider
|
||||
*
|
||||
* Wraps the application with event bus context.
|
||||
* Initializes default middleware on mount.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage
|
||||
* <EventBusProvider>
|
||||
* <App />
|
||||
* </EventBusProvider>
|
||||
*
|
||||
* // With all middleware disabled except custom
|
||||
* <EventBusProvider
|
||||
* enableLogging={false}
|
||||
* enableActivityLogging={false}
|
||||
* middleware={[customMiddleware]}
|
||||
* >
|
||||
* <App />
|
||||
* </EventBusProvider>
|
||||
*
|
||||
* // For testing with isolated bus
|
||||
* <EventBusProvider bus={testBus}>
|
||||
* <ComponentUnderTest />
|
||||
* </EventBusProvider>
|
||||
* ```
|
||||
*/
|
||||
export function EventBusProvider({
|
||||
children,
|
||||
middleware = [],
|
||||
enableLogging = process.env.NODE_ENV === 'development',
|
||||
enableActivityLogging = true,
|
||||
enableNotifications = true,
|
||||
bus = eventBus,
|
||||
}: EventBusProviderProps) {
|
||||
// Register middleware and handlers on mount
|
||||
useEffect(() => {
|
||||
const registeredMiddleware: EventMiddleware[] = [];
|
||||
let handlerCleanup: (() => void) | undefined;
|
||||
|
||||
// Register middleware in order (logging first for debugging)
|
||||
if (enableLogging) {
|
||||
bus.use(loggingMiddleware);
|
||||
registeredMiddleware.push(loggingMiddleware);
|
||||
}
|
||||
|
||||
if (enableActivityLogging) {
|
||||
bus.use(activityMiddleware);
|
||||
registeredMiddleware.push(activityMiddleware);
|
||||
}
|
||||
|
||||
if (enableNotifications) {
|
||||
bus.use(notificationMiddleware);
|
||||
registeredMiddleware.push(notificationMiddleware);
|
||||
}
|
||||
|
||||
// Add custom middleware
|
||||
for (const mw of middleware) {
|
||||
bus.use(mw);
|
||||
registeredMiddleware.push(mw);
|
||||
}
|
||||
|
||||
// Setup event handlers
|
||||
handlerCleanup = setupEventHandlers(bus);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
for (const mw of registeredMiddleware) {
|
||||
bus.removeMiddleware(mw);
|
||||
}
|
||||
handlerCleanup?.();
|
||||
};
|
||||
}, [bus, enableLogging, enableActivityLogging, enableNotifications, middleware]);
|
||||
|
||||
// Memoize context value
|
||||
const contextValue = useMemo<EventBusContextValue>(() => ({ bus }), [bus]);
|
||||
|
||||
return <EventBusContext.Provider value={contextValue}>{children}</EventBusContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the event bus
|
||||
*
|
||||
* @returns The event bus instance
|
||||
* @throws Error if used outside EventBusProvider
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const bus = useEventBus();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const sub = bus.on('llm:response', (event) => {
|
||||
* console.log('Response:', event.content);
|
||||
* });
|
||||
* return () => sub.unsubscribe();
|
||||
* }, [bus]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useEventBus(): ClientEventBus {
|
||||
const context = useContext(EventBusContext);
|
||||
if (!context) {
|
||||
throw new Error('useEventBus must be used within an EventBusProvider');
|
||||
}
|
||||
return context.bus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to events with automatic cleanup
|
||||
*
|
||||
* @param eventName - Event name to subscribe to
|
||||
* @param handler - Handler function
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MessageList() {
|
||||
* const [messages, setMessages] = useState<Message[]>([]);
|
||||
*
|
||||
* useEventSubscription('llm:response', (event) => {
|
||||
* setMessages(prev => [...prev, {
|
||||
* role: 'assistant',
|
||||
* content: event.content,
|
||||
* }]);
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useEventSubscription<T extends Parameters<ClientEventBus['on']>[0]>(
|
||||
eventName: T,
|
||||
handler: Parameters<ClientEventBus['on']>[1]
|
||||
): void {
|
||||
const bus = useEventBus();
|
||||
|
||||
useEffect(() => {
|
||||
// Type assertion is needed here because the generic relationship between
|
||||
// eventName and handler is complex - the EventBus.on() validates at runtime
|
||||
const subscription = bus.on(eventName as any, handler as any);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [bus, eventName, handler]);
|
||||
}
|
||||
20
dexto/packages/webui/components/providers/QueryProvider.tsx
Normal file
20
dexto/packages/webui/components/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
114
dexto/packages/webui/components/settings/SettingsNavigation.tsx
Normal file
114
dexto/packages/webui/components/settings/SettingsNavigation.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Key, Volume2, Palette, X, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type SettingsSection = 'api-keys' | 'voice' | 'appearance';
|
||||
|
||||
type SettingsNavigationProps = {
|
||||
activeSection: SettingsSection;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
onClose: () => void;
|
||||
variant?: 'desktop' | 'mobile';
|
||||
};
|
||||
|
||||
const sections: { id: SettingsSection; label: string; icon: typeof Key }[] = [
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||
{ id: 'voice', label: 'Voice & TTS', icon: Volume2 },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
];
|
||||
|
||||
export function SettingsNavigation({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onClose,
|
||||
variant = 'desktop',
|
||||
}: SettingsNavigationProps) {
|
||||
if (variant === 'mobile') {
|
||||
const activeItem = sections.find((s) => s.id === activeSection);
|
||||
const ActiveIcon = activeItem?.icon || Key;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-2">
|
||||
<ActiveIcon className="h-4 w-4" />
|
||||
<span>{activeItem?.label}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={section.id}
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
activeSection === section.id && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{section.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close settings">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-border">
|
||||
<h2 className="font-semibold">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onClose}
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isActive = activeSection === section.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
dexto/packages/webui/components/settings/SettingsPanel.tsx
Normal file
91
dexto/packages/webui/components/settings/SettingsPanel.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
||||
import { SettingsNavigation, type SettingsSection } from './SettingsNavigation';
|
||||
import { ApiKeysSection } from './sections/ApiKeysSection';
|
||||
import { VoiceSection } from './sections/VoiceSection';
|
||||
import { AppearanceSection } from './sections/AppearanceSection';
|
||||
|
||||
type SettingsPanelProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const sectionTitles: Record<SettingsSection, string> = {
|
||||
'api-keys': 'API Keys',
|
||||
voice: 'Voice & TTS',
|
||||
appearance: 'Appearance',
|
||||
};
|
||||
|
||||
const sectionDescriptions: Record<SettingsSection, string> = {
|
||||
'api-keys': 'Manage API keys for LLM providers',
|
||||
voice: 'Configure text-to-speech settings',
|
||||
appearance: 'Customize theme and UI preferences',
|
||||
};
|
||||
|
||||
export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('api-keys');
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSection) {
|
||||
case 'api-keys':
|
||||
return <ApiKeysSection />;
|
||||
case 'voice':
|
||||
return <VoiceSection active={isOpen} />;
|
||||
case 'appearance':
|
||||
return <AppearanceSection />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-4xl! w-[90vw] h-[85vh] p-0! gap-0! flex flex-col"
|
||||
hideCloseButton
|
||||
>
|
||||
{/* Visually hidden title for accessibility */}
|
||||
<DialogTitle className="sr-only">Settings</DialogTitle>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left Navigation - hidden on mobile, shown on md+ */}
|
||||
<div className="hidden md:flex md:flex-col w-56 border-r border-border bg-muted/30 shrink-0">
|
||||
<SettingsNavigation
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{/* Mobile header with navigation */}
|
||||
<div className="md:hidden flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
||||
<SettingsNavigation
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
variant="mobile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className="hidden md:block px-6 py-4 border-b border-border shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{sectionTitles[activeSection]}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{sectionDescriptions[activeSection]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section content - scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useLLMCatalog,
|
||||
useSaveApiKey,
|
||||
useProviderApiKey,
|
||||
type LLMProvider,
|
||||
} from '../../hooks/useLLM';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Alert, AlertDescription } from '../../ui/alert';
|
||||
import { Check, Eye, EyeOff, ExternalLink, Loader2 } from 'lucide-react';
|
||||
|
||||
// Provider info with display names and key URLs
|
||||
const PROVIDER_INFO: Record<
|
||||
string,
|
||||
{ displayName: string; keyUrl?: string; description?: string }
|
||||
> = {
|
||||
openai: {
|
||||
displayName: 'OpenAI',
|
||||
keyUrl: 'https://platform.openai.com/api-keys',
|
||||
description: 'GPT models',
|
||||
},
|
||||
anthropic: {
|
||||
displayName: 'Anthropic',
|
||||
keyUrl: 'https://console.anthropic.com/settings/keys',
|
||||
description: 'Claude models',
|
||||
},
|
||||
google: {
|
||||
displayName: 'Google AI',
|
||||
keyUrl: 'https://aistudio.google.com/apikey',
|
||||
description: 'Gemini models (Free tier available)',
|
||||
},
|
||||
groq: {
|
||||
displayName: 'Groq',
|
||||
keyUrl: 'https://console.groq.com/keys',
|
||||
description: 'Fast inference',
|
||||
},
|
||||
xai: {
|
||||
displayName: 'xAI',
|
||||
keyUrl: 'https://console.x.ai/team/default/api-keys',
|
||||
description: 'Grok models',
|
||||
},
|
||||
cohere: {
|
||||
displayName: 'Cohere',
|
||||
keyUrl: 'https://dashboard.cohere.com/api-keys',
|
||||
description: 'Command models',
|
||||
},
|
||||
openrouter: {
|
||||
displayName: 'OpenRouter',
|
||||
keyUrl: 'https://openrouter.ai/keys',
|
||||
description: 'Multi-provider gateway',
|
||||
},
|
||||
glama: {
|
||||
displayName: 'Glama',
|
||||
keyUrl: 'https://glama.ai/settings/api-keys',
|
||||
description: 'OpenAI-compatible',
|
||||
},
|
||||
ollama: {
|
||||
displayName: 'Ollama',
|
||||
description: 'Local models (no key needed)',
|
||||
},
|
||||
local: {
|
||||
displayName: 'Local',
|
||||
description: 'GGUF models (no key needed)',
|
||||
},
|
||||
};
|
||||
|
||||
// Providers that don't need API keys or need special configuration
|
||||
// These are handled by the ModelPicker's custom model form instead
|
||||
const EXCLUDED_PROVIDERS = [
|
||||
'ollama', // Local, no key needed
|
||||
'local', // Local GGUF, no key needed
|
||||
'openai-compatible', // Needs baseURL + model name (use ModelPicker)
|
||||
'litellm', // Needs baseURL (use ModelPicker)
|
||||
'bedrock', // Uses AWS credentials, not API key
|
||||
'vertex', // Uses Google Cloud ADC, not API key
|
||||
];
|
||||
|
||||
type ProviderRowProps = {
|
||||
provider: LLMProvider;
|
||||
hasKey: boolean;
|
||||
envVar: string;
|
||||
onSave: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function ProviderRow({ provider, hasKey, envVar, onSave }: ProviderRowProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
const info = PROVIDER_INFO[provider] || { displayName: provider };
|
||||
|
||||
// Query for masked key value when has key
|
||||
const { data: keyData } = useProviderApiKey(hasKey ? provider : null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setError('API key is required');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(apiKey);
|
||||
setApiKey('');
|
||||
setIsEditing(false);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setApiKey('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3 px-4 rounded-lg border border-border">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{info.displayName}</span>
|
||||
{hasKey && !isEditing && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<Check className="h-3 w-3" />
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Saved!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{info.description}</div>
|
||||
{hasKey && keyData?.keyValue && !isEditing && (
|
||||
<div className="mt-1 text-xs text-muted-foreground font-mono">
|
||||
{keyData.keyValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{info.keyUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => window.open(info.keyUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isEditing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||
{hasKey ? 'Update' : 'Add Key'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">API Key ({envVar})</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={`Enter ${info.displayName} API key`}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription className="text-sm">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const { data: catalog, isLoading, error } = useLLMCatalog({ mode: 'grouped' });
|
||||
const { mutateAsync: saveApiKey } = useSaveApiKey();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>Failed to load providers: {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog || !('providers' in catalog)) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>No providers available</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const providers = Object.entries(catalog.providers) as [
|
||||
LLMProvider,
|
||||
{ hasApiKey: boolean; primaryEnvVar: string },
|
||||
][];
|
||||
|
||||
// Filter out providers handled elsewhere (openai-compatible is in Default Model)
|
||||
const regularProviders = providers.filter(([id]) => !EXCLUDED_PROVIDERS.includes(id));
|
||||
|
||||
// Sort: configured first, then by display name
|
||||
const sortedProviders = regularProviders.sort((a, b) => {
|
||||
const aHasKey = a[1].hasApiKey;
|
||||
const bHasKey = b[1].hasApiKey;
|
||||
if (aHasKey !== bHasKey) return bHasKey ? 1 : -1;
|
||||
|
||||
const aName = PROVIDER_INFO[a[0]]?.displayName || a[0];
|
||||
const bName = PROVIDER_INFO[b[0]]?.displayName || b[0];
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
const handleSave = async (provider: LLMProvider, apiKey: string) => {
|
||||
await saveApiKey({ provider, apiKey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
API keys are stored securely in your local .env file and are never shared with third
|
||||
parties.
|
||||
</p>
|
||||
|
||||
{sortedProviders.map(([provider, info]) => (
|
||||
<ProviderRow
|
||||
key={provider}
|
||||
provider={provider}
|
||||
hasKey={info.hasApiKey}
|
||||
envVar={info.primaryEnvVar}
|
||||
onSave={(key) => handleSave(provider, key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { usePreferenceStore } from '@/lib/stores/preferenceStore';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { Moon, Sun, Zap } from 'lucide-react';
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isStreaming, setStreaming } = usePreferenceStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the look and feel of the application.
|
||||
</p>
|
||||
|
||||
{/* Theme Setting */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-muted">
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Dark Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Switch between light and dark themes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={toggleTheme}
|
||||
aria-label="Toggle dark mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming Setting */}
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-muted">
|
||||
<Zap className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Streaming Responses</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show responses as they are generated (recommended)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isStreaming}
|
||||
onCheckedChange={setStreaming}
|
||||
aria-label="Toggle streaming mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Label } from '../../ui/label';
|
||||
import { SpeechVoiceSelect } from '../../ui/speech-voice-select';
|
||||
|
||||
type VoiceSectionProps = {
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function VoiceSection({ active = false }: VoiceSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure text-to-speech settings for voice output.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voice-select">Voice Selection</Label>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Choose a preferred text-to-speech voice. "Auto" selects the best available
|
||||
voice on your device.
|
||||
</p>
|
||||
<SpeechVoiceSelect id="voice-select" active={active} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
dexto/packages/webui/components/tool-renderers/DiffRenderer.tsx
Normal file
334
dexto/packages/webui/components/tool-renderers/DiffRenderer.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* DiffRenderer Component
|
||||
*
|
||||
* Renders unified diff with syntax highlighting.
|
||||
* Shows filename, +N/-M stats, and colored diff lines.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileEdit, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DiffDisplayData } from '@dexto/core';
|
||||
|
||||
interface DiffRendererProps {
|
||||
/** Diff display data from tool result */
|
||||
data: DiffDisplayData;
|
||||
/** Maximum lines before truncation (default: 50) */
|
||||
maxLines?: number;
|
||||
/** Whether to start expanded (default: false) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Diff Parsing (ported from CLI)
|
||||
// =============================================================================
|
||||
|
||||
interface ParsedHunk {
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
lines: ParsedLine[];
|
||||
}
|
||||
|
||||
interface ParsedLine {
|
||||
type: 'context' | 'addition' | 'deletion';
|
||||
content: string;
|
||||
lineNum: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff into structured hunks.
|
||||
*/
|
||||
function parseUnifiedDiff(unified: string): ParsedHunk[] {
|
||||
const lines = unified.split('\n');
|
||||
const hunks: ParsedHunk[] = [];
|
||||
let currentHunk: ParsedHunk | null = null;
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
oldLine = parseInt(hunkMatch[1]!, 10);
|
||||
newLine = parseInt(hunkMatch[3]!, 10);
|
||||
currentHunk = {
|
||||
oldStart: oldLine,
|
||||
newStart: newLine,
|
||||
lines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.slice(1),
|
||||
lineNum: newLine++,
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.slice(1),
|
||||
lineNum: oldLine++,
|
||||
});
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.startsWith(' ') ? line.slice(1) : line,
|
||||
lineNum: newLine,
|
||||
});
|
||||
oldLine++;
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line number width for consistent alignment.
|
||||
*/
|
||||
function getLineNumWidth(maxLineNum: number): number {
|
||||
return Math.max(3, String(maxLineNum).length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line number with padding.
|
||||
*/
|
||||
function formatLineNum(num: number, width: number): string {
|
||||
return String(num).padStart(width, ' ');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Line Components
|
||||
// =============================================================================
|
||||
|
||||
interface DiffLineProps {
|
||||
type: 'context' | 'addition' | 'deletion';
|
||||
lineNum: number;
|
||||
lineNumWidth: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single diff line with gutter and content.
|
||||
*/
|
||||
function DiffLine({ type, lineNum, lineNumWidth, content }: DiffLineProps) {
|
||||
const lineNumStr = formatLineNum(lineNum, lineNumWidth);
|
||||
|
||||
const getStyles = () => {
|
||||
switch (type) {
|
||||
case 'deletion':
|
||||
return {
|
||||
bg: 'bg-red-100/50 dark:bg-red-900/20',
|
||||
text: 'text-red-800 dark:text-red-300',
|
||||
symbol: '-',
|
||||
symbolColor: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'addition':
|
||||
return {
|
||||
bg: 'bg-green-100/50 dark:bg-green-900/20',
|
||||
text: 'text-green-800 dark:text-green-300',
|
||||
symbol: '+',
|
||||
symbolColor: 'text-green-600 dark:text-green-400',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: '',
|
||||
text: 'text-foreground/60',
|
||||
symbol: ' ',
|
||||
symbolColor: 'text-transparent',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-[11px] leading-5', styles.bg)}>
|
||||
{/* Gutter: line number + symbol */}
|
||||
<div className="flex-shrink-0 select-none">
|
||||
<span className="text-muted-foreground/50 px-1">{lineNumStr}</span>
|
||||
<span className={cn('px-0.5', styles.symbolColor)}>{styles.symbol}</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<pre className={cn('flex-1 px-1 whitespace-pre-wrap break-all', styles.text)}>
|
||||
{content || ' '}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hunk separator.
|
||||
*/
|
||||
function HunkSeparator() {
|
||||
return (
|
||||
<div className="text-muted-foreground text-[10px] py-0.5 px-2 bg-muted/20">
|
||||
<span className="text-muted-foreground/60">···</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract relative path from full path.
|
||||
*/
|
||||
function getRelativePath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 3) return path;
|
||||
return `.../${parts.slice(-3).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders unified diff with syntax highlighting and line numbers.
|
||||
*/
|
||||
export function DiffRenderer({ data, maxLines = 50, defaultExpanded = false }: DiffRendererProps) {
|
||||
const { unified, filename, additions, deletions } = data;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const hunks = parseUnifiedDiff(unified);
|
||||
|
||||
// Calculate max line number for width
|
||||
let maxLineNum = 1;
|
||||
let totalLines = 0;
|
||||
for (const hunk of hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
maxLineNum = Math.max(maxLineNum, line.lineNum);
|
||||
totalLines++;
|
||||
}
|
||||
}
|
||||
const lineNumWidth = getLineNumWidth(maxLineNum);
|
||||
|
||||
const shouldTruncate = totalLines > maxLines && !showAll;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(unified);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Header with filename and stats */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FileEdit className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80 truncate" title={filename}>
|
||||
{getRelativePath(filename)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-green-600 dark:text-green-400">
|
||||
+{additions}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||
-{deletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="pl-5">
|
||||
{!expanded ? (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>
|
||||
Show diff ({totalLines} line{totalLines !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>Diff</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-md overflow-hidden border border-border/50">
|
||||
<div className="max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{(() => {
|
||||
let linesRendered = 0;
|
||||
return hunks.map((hunk, hunkIndex) => {
|
||||
if (shouldTruncate && linesRendered >= maxLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
{hunkIndex > 0 && <HunkSeparator />}
|
||||
{hunk.lines.map((line, lineIndex) => {
|
||||
if (
|
||||
shouldTruncate &&
|
||||
linesRendered >= maxLines
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
linesRendered++;
|
||||
return (
|
||||
<DiffLine
|
||||
key={`${hunkIndex}-${lineIndex}`}
|
||||
type={line.type}
|
||||
lineNum={line.lineNum}
|
||||
lineNumWidth={lineNumWidth}
|
||||
content={line.content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full py-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 bg-muted/50 border-t border-border/50"
|
||||
>
|
||||
Show {totalLines - maxLines} more lines...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
dexto/packages/webui/components/tool-renderers/FileRenderer.tsx
Normal file
114
dexto/packages/webui/components/tool-renderers/FileRenderer.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* FileRenderer Component
|
||||
*
|
||||
* Renders file operation metadata (read, write, create, delete).
|
||||
* Compact single-line format with operation badge.
|
||||
*/
|
||||
|
||||
import { FileText, FilePlus, FileX, FileEdit } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileDisplayData } from '@dexto/core';
|
||||
|
||||
interface FileRendererProps {
|
||||
/** File display data from tool result */
|
||||
data: FileDisplayData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation icon and color based on operation type.
|
||||
*/
|
||||
function getOperationInfo(operation: FileDisplayData['operation']) {
|
||||
switch (operation) {
|
||||
case 'read':
|
||||
return {
|
||||
icon: FileText,
|
||||
label: 'Read',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
};
|
||||
case 'write':
|
||||
return {
|
||||
icon: FileEdit,
|
||||
label: 'Updated',
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
};
|
||||
case 'create':
|
||||
return {
|
||||
icon: FilePlus,
|
||||
label: 'Created',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
};
|
||||
case 'delete':
|
||||
return {
|
||||
icon: FileX,
|
||||
label: 'Deleted',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format.
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract relative path (last 2-3 segments) from full path.
|
||||
*/
|
||||
function getRelativePath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 3) return path;
|
||||
return `.../${parts.slice(-3).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders file operation summary as a compact single-line card.
|
||||
*/
|
||||
export function FileRenderer({ data }: FileRendererProps) {
|
||||
const { path, operation, size, lineCount } = data;
|
||||
const opInfo = getOperationInfo(operation);
|
||||
const Icon = opInfo.icon;
|
||||
|
||||
const metadata: string[] = [];
|
||||
if (lineCount !== undefined) {
|
||||
metadata.push(`${lineCount} line${lineCount !== 1 ? 's' : ''}`);
|
||||
}
|
||||
if (size !== undefined) {
|
||||
metadata.push(formatSize(size));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{/* Operation badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium',
|
||||
opInfo.bgColor,
|
||||
opInfo.color
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span>{opInfo.label}</span>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<span className="font-mono text-xs text-foreground/80 truncate" title={path}>
|
||||
{getRelativePath(path)}
|
||||
</span>
|
||||
|
||||
{/* Metadata */}
|
||||
{metadata.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
({metadata.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user