350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import rehypeRaw from 'rehype-raw';
|
|
import { toast } from 'sonner';
|
|
import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { useGlobal } from 'hooks/useGlobal';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { usePiChat, EmbeddableChat } from 'officerdev';
|
|
import { useResources, type ResourceDetail, type PingResult } from 'state/useResources';
|
|
import { FrontmatterBlock } from '../../CapabilityPage';
|
|
|
|
const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key);
|
|
|
|
type ConfigEditorProps = {
|
|
resourceName: string;
|
|
config: Record<string, string>;
|
|
onSaved: () => void;
|
|
};
|
|
|
|
const ConfigEditor = ({ resourceName, config, onSaved }: ConfigEditorProps) => {
|
|
const { saveConfig, pingResource } = useResources();
|
|
const configEntries = Object.entries(config);
|
|
const [fields, setFields] = useState<[string, string][]>(configEntries);
|
|
const [newKey, setNewKey] = useState('');
|
|
const [newValue, setNewValue] = useState('');
|
|
const [pinging, setPinging] = useState(false);
|
|
const [pingResult, setPingResult] = useState<PingResult | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setFields(Object.entries(config));
|
|
}, [config]);
|
|
|
|
const hasUrl = fields.some(([k, v]) => k === 'url' && v);
|
|
|
|
const handleAddField = () => {
|
|
const key = newKey.trim();
|
|
if (!key || fields.some(([k]) => k === key)) return;
|
|
setFields([...fields, [key, newValue]]);
|
|
setNewKey('');
|
|
setNewValue('');
|
|
};
|
|
|
|
const handleRemoveField = (index: number) => {
|
|
setFields(fields.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleFieldValue = (index: number, value: string) => {
|
|
setFields(fields.map((f, i) => (i === index ? [f[0]!, value] : f)));
|
|
};
|
|
|
|
const handlePing = async () => {
|
|
const url = fields.find(([k]) => k === 'url')?.[1];
|
|
if (!url) return;
|
|
setPinging(true);
|
|
setPingResult(null);
|
|
try {
|
|
const result = await pingResource(resourceName, url);
|
|
setPingResult(result);
|
|
} catch {
|
|
setPingResult({ reachable: false, latencyMs: null });
|
|
} finally {
|
|
setPinging(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const oldKeys = Object.keys(config);
|
|
const newKeys = new Set(fields.map(([k]) => k));
|
|
const patch: Record<string, string | null> = {};
|
|
for (const [key, value] of fields) {
|
|
patch[key] = value;
|
|
}
|
|
for (const key of oldKeys) {
|
|
if (!newKeys.has(key)) patch[key] = null;
|
|
}
|
|
await saveConfig(resourceName, patch);
|
|
onSaved();
|
|
toast.success('Configuration saved');
|
|
} catch {
|
|
toast.error('Failed to save configuration');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-semibold text-duck-dark mb-3">Configuration</h3>
|
|
<div className="flex flex-col gap-2">
|
|
{fields.map(([key, value], index) => (
|
|
<div key={key} className="flex items-center gap-2">
|
|
<label className="text-xs text-duck-dark/50 w-28 shrink-0 truncate" title={key}>
|
|
{key}
|
|
</label>
|
|
<Input
|
|
value={value}
|
|
onChange={(ev) => handleFieldValue(index, ev.target.value)}
|
|
type={isSensitiveKey(key) ? 'password' : 'text'}
|
|
className="h-8 text-xs flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-red-500 cursor-pointer"
|
|
onClick={() => handleRemoveField(index)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={newKey}
|
|
onChange={(ev) => setNewKey(ev.target.value)}
|
|
placeholder="key"
|
|
className="h-8 text-xs w-28 shrink-0"
|
|
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
|
|
/>
|
|
<Input
|
|
value={newValue}
|
|
onChange={(ev) => setNewValue(ev.target.value)}
|
|
placeholder="value"
|
|
className="h-8 text-xs flex-1"
|
|
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-duck-teal cursor-pointer"
|
|
onClick={handleAddField}
|
|
disabled={!newKey.trim()}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{hasUrl && (
|
|
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging} className="text-xs cursor-pointer">
|
|
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
|
Test Connection
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={handleSave} disabled={saving} className="text-xs cursor-pointer">
|
|
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
|
Save
|
|
</Button>
|
|
{pingResult && (
|
|
<span className={`text-xs ${pingResult.reachable ? 'text-green-600' : 'text-red-500'}`}>
|
|
{pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ResourceChatProps = {
|
|
detail: ResourceDetail;
|
|
isNew?: boolean;
|
|
onResponseEnd: () => void;
|
|
};
|
|
|
|
const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => {
|
|
const promptFrontmatter = `<frontmatter>\nconfig file: ${detail.configPath}\nresource file: ${detail.filePath}\nguide: ${detail.guidePath}\n\nYou are helping configure a resource. Read the GUIDE.md for instructions on how to help. Read the RESOURCE.md for context about what this resource is. Write config values to the config.json file.\n</frontmatter>`;
|
|
const defaultInput = isNew
|
|
? 'Help me set up this new resource'
|
|
: 'Help me configure this resource';
|
|
|
|
const pi = usePiChat(undefined, undefined, { replaceUrl: false });
|
|
|
|
const onResponseEndRef = useRef(onResponseEnd);
|
|
onResponseEndRef.current = onResponseEnd;
|
|
|
|
const wasGenerating = useRef(false);
|
|
useEffect(() => {
|
|
if (wasGenerating.current && !pi.isGenerating) {
|
|
onResponseEndRef.current();
|
|
}
|
|
wasGenerating.current = pi.isGenerating;
|
|
}, [pi.isGenerating]);
|
|
|
|
return (
|
|
<EmbeddableChat
|
|
chat={pi}
|
|
defaultInput={defaultInput}
|
|
promptPrefix={promptFrontmatter}
|
|
className="h-full"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const Resources = () => {
|
|
const client = useClient();
|
|
const qc = useQueryClient();
|
|
const [selectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
|
const [editing, setEditing] = useState(false);
|
|
const [isNew, setIsNew] = useState(false);
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
const [showDetail, setShowDetail] = useState(false);
|
|
|
|
const { data: detail, refetch } = useQuery<ResourceDetail>({
|
|
queryKey: ['RESOURCES', selectedName],
|
|
queryFn: () => client.get<ResourceDetail>(`/server-settings/resources/${selectedName}`),
|
|
enabled: !!selectedName,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (selectedName) {
|
|
setShowDetail(true);
|
|
setEditing(false);
|
|
}
|
|
}, [selectedName]);
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedName) return;
|
|
try {
|
|
await client.delete(`/server-settings/resources/${selectedName}`);
|
|
setDeleteConfirm(false);
|
|
setEditing(false);
|
|
await qc.invalidateQueries({ queryKey: ['RESOURCES'] });
|
|
} catch {
|
|
toast.error('Failed to delete resource');
|
|
}
|
|
};
|
|
|
|
const canDelete = detail && detail.scope === 'global' && !detail.filePath.includes('/seed/');
|
|
|
|
if (!selectedName || !detail) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<p className="text-sm text-duck-dark/40">Select a resource to view its configuration</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col h-full">
|
|
{/* Detail panel */}
|
|
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowDetail(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail.name}</span>
|
|
<span
|
|
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
|
detail.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
|
|
}`}
|
|
>
|
|
{detail.scope}
|
|
</span>
|
|
<button
|
|
onClick={() => setEditing((e) => !e)}
|
|
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
|
</button>
|
|
{canDelete && (
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="overflow-y-auto flex-1 p-6">
|
|
{detail.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
|
{detail.body && (
|
|
<article className="skill-md mb-6">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
{detail.body}
|
|
</ReactMarkdown>
|
|
</article>
|
|
)}
|
|
<ConfigEditor
|
|
key={selectedName}
|
|
resourceName={selectedName}
|
|
config={detail.config}
|
|
onSaved={() => refetch()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chat panel */}
|
|
{editing && (
|
|
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name} — Chat</span>
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
|
</button>
|
|
</div>
|
|
<ResourceChat
|
|
key={detail.filePath}
|
|
detail={detail}
|
|
isNew={isNew}
|
|
onResponseEnd={() => {
|
|
refetch();
|
|
qc.invalidateQueries({ queryKey: ['RESOURCES'] });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
|
<DialogContent className="sm:max-w-md z-[700]">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Resource</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete "{detail.name}"? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|