feat(local-llm): Phase C custom agents (C1-C5)
- add built-in agents library (10 seeded agents) - add agent CRUD/seeding/export/import helpers in db layer - seed agents on workspace load - add agent strip in sidebar and launch-from-agent flow - add command palette support for agent entries - add agent conversation wiring (agentId, systemPrompt, welcome message) - render agent badge in conversation header - add example prompt chips in input bar for agent conversations - add AgentEditor modal for creating/updating custom agents
This commit is contained in:
parent
f289099461
commit
d18b695029
@ -3,13 +3,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getConversation, getMessages } from '../../../lib/db';
|
||||
import type { Conversation, Message } from '../../../lib/types';
|
||||
import { getAgent, getConversation, getMessages } from '../../../lib/db';
|
||||
import type { Agent, Conversation, Message } from '../../../lib/types';
|
||||
import { ConversationView } from '../../components/ConversationView';
|
||||
|
||||
export default function ConversationPage({ params }: { params: { id: string } }) {
|
||||
const searchParams = useSearchParams();
|
||||
const [conversation, setConversation] = useState<Conversation | null>(null);
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@ -21,7 +22,9 @@ export default function ConversationPage({ params }: { params: { id: string } })
|
||||
return;
|
||||
}
|
||||
const msgRows = await getMessages(params.id, { limit: 200 });
|
||||
const agentRow = row.agentId ? await getAgent(row.agentId) : undefined;
|
||||
setConversation(row);
|
||||
setAgent(agentRow || null);
|
||||
setMessages(msgRows);
|
||||
setLoading(false);
|
||||
};
|
||||
@ -54,6 +57,7 @@ export default function ConversationPage({ params }: { params: { id: string } })
|
||||
conversation={conversation}
|
||||
initialMessages={messages}
|
||||
initialTemplate={searchParams.get('template') || ''}
|
||||
agent={agent || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Agent, AgentTool } from '../../lib/types';
|
||||
|
||||
interface AgentEditorProps {
|
||||
open: boolean;
|
||||
editing?: Agent | null;
|
||||
onClose: () => void;
|
||||
onSave: (agent: Agent) => Promise<void>;
|
||||
}
|
||||
|
||||
const TOOL_OPTIONS: AgentTool[] = ['file_read', 'vision', 'voice_input'];
|
||||
|
||||
export function AgentEditor({ open, editing, onClose, onSave }: AgentEditorProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [icon, setIcon] = useState('🤖');
|
||||
const [description, setDescription] = useState('');
|
||||
const [model, setModel] = useState('chat');
|
||||
const [temperature, setTemperature] = useState<number>(0.7);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [welcomeMessage, setWelcomeMessage] = useState('');
|
||||
const [examplePrompts, setExamplePrompts] = useState('');
|
||||
const [responseFormat, setResponseFormat] = useState<Agent['responseFormat']>('markdown');
|
||||
const [tools, setTools] = useState<AgentTool[]>(['file_read']);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setName(editing.name);
|
||||
setIcon(editing.icon);
|
||||
setDescription(editing.description);
|
||||
setModel(editing.model);
|
||||
setTemperature(editing.temperature ?? 0.7);
|
||||
setSystemPrompt(editing.systemPrompt);
|
||||
setWelcomeMessage(editing.welcomeMessage || '');
|
||||
setExamplePrompts((editing.examplePrompts || []).join('\n'));
|
||||
setResponseFormat(editing.responseFormat || 'markdown');
|
||||
setTools(editing.tools || ['file_read']);
|
||||
return;
|
||||
}
|
||||
|
||||
setName('');
|
||||
setIcon('🤖');
|
||||
setDescription('');
|
||||
setModel('chat');
|
||||
setTemperature(0.7);
|
||||
setSystemPrompt('');
|
||||
setWelcomeMessage('');
|
||||
setExamplePrompts('');
|
||||
setResponseFormat('markdown');
|
||||
setTools(['file_read']);
|
||||
}, [open, editing]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canSave = name.trim().length > 0 && systemPrompt.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-3xl rounded-lg border border-white/10 bg-[var(--surface-card)] p-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-3 text-lg font-semibold text-[var(--text-primary)]">
|
||||
{editing ? 'Edit Agent' : 'Create Agent'}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Icon
|
||||
<input
|
||||
value={icon}
|
||||
onChange={e => setIcon(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)] md:col-span-2">
|
||||
Description
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Model
|
||||
<input
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Temperature ({temperature.toFixed(1)})
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={e => setTemperature(Number(e.target.value))}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)] md:col-span-2">
|
||||
System Prompt
|
||||
<textarea
|
||||
rows={6}
|
||||
value={systemPrompt}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)] md:col-span-2">
|
||||
Welcome Message
|
||||
<input
|
||||
value={welcomeMessage}
|
||||
onChange={e => setWelcomeMessage(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)] md:col-span-2">
|
||||
Example Prompts (one per line)
|
||||
<textarea
|
||||
rows={4}
|
||||
value={examplePrompts}
|
||||
onChange={e => setExamplePrompts(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Response Format
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={e => setResponseFormat(e.target.value as Agent['responseFormat'])}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
<option value="markdown">markdown</option>
|
||||
<option value="json">json</option>
|
||||
<option value="code">code</option>
|
||||
<option value="plain">plain</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Tools
|
||||
<div className="mt-2 space-y-1">
|
||||
{TOOL_OPTIONS.map(tool => (
|
||||
<label key={tool} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tools.includes(tool)}
|
||||
onChange={e => {
|
||||
setTools(prev => {
|
||||
if (e.target.checked) return Array.from(new Set([...prev, tool]));
|
||||
return prev.filter(item => item !== tool);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{tool}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-3 py-1 text-sm text-[var(--text-secondary)] hover:bg-white/5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSave || saving}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
const agent: Agent = {
|
||||
id: editing?.id || crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || '🤖',
|
||||
description: description.trim(),
|
||||
model: model.trim() || 'chat',
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
temperature,
|
||||
tools,
|
||||
welcomeMessage: welcomeMessage.trim() || undefined,
|
||||
examplePrompts: examplePrompts
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
constraints: editing?.constraints,
|
||||
responseFormat,
|
||||
builtin: false,
|
||||
conversationCount: editing?.conversationCount ?? 0,
|
||||
createdAt: editing?.createdAt ?? Date.now(),
|
||||
};
|
||||
await onSave(agent);
|
||||
setSaving(false);
|
||||
onClose();
|
||||
}}
|
||||
className="rounded bg-[var(--accent-primary)] px-3 py-1 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import type { Conversation, QuickAction } from '../../lib/types';
|
||||
import type { Agent, Conversation, QuickAction } from '../../lib/types';
|
||||
|
||||
type CommandItem =
|
||||
| {
|
||||
@ -13,6 +13,7 @@ type CommandItem =
|
||||
icon: string;
|
||||
payload: QuickAction;
|
||||
}
|
||||
| { type: 'agent'; id: string; name: string; description: string; icon: string; payload: Agent }
|
||||
| {
|
||||
type: 'conversation';
|
||||
id: string;
|
||||
@ -33,9 +34,11 @@ type CommandItem =
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
quickActions: QuickAction[];
|
||||
agents: Agent[];
|
||||
conversations: Conversation[];
|
||||
onClose: () => void;
|
||||
onSelectQuickAction: (qa: QuickAction) => void;
|
||||
onSelectAgent: (agent: Agent) => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onSelectSystem: (action: 'mission-control' | 'settings' | 'export') => void;
|
||||
}
|
||||
@ -43,9 +46,11 @@ interface CommandPaletteProps {
|
||||
export function CommandPalette({
|
||||
open,
|
||||
quickActions,
|
||||
agents,
|
||||
conversations,
|
||||
onClose,
|
||||
onSelectQuickAction,
|
||||
onSelectAgent,
|
||||
onSelectConversation,
|
||||
onSelectSystem,
|
||||
}: CommandPaletteProps) {
|
||||
@ -61,6 +66,15 @@ export function CommandPalette({
|
||||
payload: qa,
|
||||
}));
|
||||
|
||||
const agentItems: CommandItem[] = agents.map(agent => ({
|
||||
type: 'agent',
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
icon: agent.icon,
|
||||
payload: agent,
|
||||
}));
|
||||
|
||||
const convItems: CommandItem[] = conversations.slice(0, 20).map(conv => ({
|
||||
type: 'conversation',
|
||||
id: conv.id,
|
||||
@ -97,8 +111,8 @@ export function CommandPalette({
|
||||
},
|
||||
];
|
||||
|
||||
return [...qaItems, ...convItems, ...systemItems];
|
||||
}, [quickActions, conversations]);
|
||||
return [...qaItems, ...agentItems, ...convItems, ...systemItems];
|
||||
}, [quickActions, agents, conversations]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return items.slice(0, 15);
|
||||
@ -143,6 +157,7 @@ export function CommandPalette({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.type === 'qa') onSelectQuickAction(item.payload);
|
||||
if (item.type === 'agent') onSelectAgent(item.payload);
|
||||
if (item.type === 'conversation') onSelectConversation(item.payload);
|
||||
if (item.type === 'system') onSelectSystem(item.action);
|
||||
onClose();
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { Conversation, Message, OllamaData } from '../../lib/types';
|
||||
import type { Agent, Conversation, Message, OllamaData } from '../../lib/types';
|
||||
import { addMessage, updateConversation } from '../../lib/db';
|
||||
import { InputBar } from './InputBar';
|
||||
import { MessageThread } from './MessageThread';
|
||||
@ -13,6 +13,7 @@ interface ConversationViewProps {
|
||||
conversation: Conversation;
|
||||
initialMessages: Message[];
|
||||
initialTemplate?: string;
|
||||
agent?: Agent;
|
||||
onConversationUpdated?: (next: Conversation) => void;
|
||||
}
|
||||
|
||||
@ -20,6 +21,7 @@ export function ConversationView({
|
||||
conversation,
|
||||
initialMessages,
|
||||
initialTemplate,
|
||||
agent,
|
||||
onConversationUpdated,
|
||||
}: ConversationViewProps) {
|
||||
const [title, setTitle] = useState(conversation.title);
|
||||
@ -228,6 +230,12 @@ export function ConversationView({
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h1>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{conversation.id}</p>
|
||||
{agent ? (
|
||||
<p className="mt-1 inline-flex items-center gap-1 rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] text-[var(--text-secondary)]">
|
||||
<span>{agent.icon}</span>
|
||||
<span>{agent.name}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
@ -272,6 +280,7 @@ export function ConversationView({
|
||||
onCancel={onCancel}
|
||||
disabled={!selectedModel}
|
||||
initialText={initialTemplate || ''}
|
||||
examplePrompts={agent?.examplePrompts || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -9,6 +9,7 @@ interface InputBarProps {
|
||||
streaming?: boolean;
|
||||
onCancel?: () => void;
|
||||
initialText?: string;
|
||||
examplePrompts?: string[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
@ -17,6 +18,7 @@ export function InputBar({
|
||||
streaming,
|
||||
onCancel,
|
||||
initialText = '',
|
||||
examplePrompts = [],
|
||||
}: InputBarProps) {
|
||||
const [text, setText] = useState(initialText);
|
||||
|
||||
@ -33,6 +35,20 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/10 bg-[var(--bg-elevated)] p-3">
|
||||
{examplePrompts.length > 0 && !text.trim() && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{examplePrompts.slice(0, 4).map(prompt => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
onClick={() => setText(prompt)}
|
||||
className="rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import type { Conversation, QuickAction } from '../../lib/types';
|
||||
import type { Agent, Conversation, QuickAction } from '../../lib/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ConversationList } from './ConversationList';
|
||||
|
||||
@ -23,6 +23,10 @@ interface SidebarProps {
|
||||
onSelectConversation: (id: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
onLaunchQuickAction: (qa: QuickAction) => void;
|
||||
agents: Agent[];
|
||||
onLaunchAgent: (agent: Agent) => void;
|
||||
onOpenQuickActionEditor: () => void;
|
||||
onOpenAgentEditor: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@ -33,6 +37,10 @@ export function Sidebar({
|
||||
onSelectConversation,
|
||||
quickActions,
|
||||
onLaunchQuickAction,
|
||||
agents,
|
||||
onLaunchAgent,
|
||||
onOpenQuickActionEditor,
|
||||
onOpenAgentEditor,
|
||||
}: SidebarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
@ -41,6 +49,7 @@ export function Sidebar({
|
||||
return conversations.filter(c => c.title.toLowerCase().includes(q));
|
||||
}, [conversations, query]);
|
||||
const topQuickActions = useMemo(() => quickActions.slice(0, 5), [quickActions]);
|
||||
const topAgents = useMemo(() => agents.slice(0, 6), [agents]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@ -63,8 +72,14 @@ export function Sidebar({
|
||||
collapsed={collapsed}
|
||||
icon={<Sparkles size={16} />}
|
||||
label="Quick Actions"
|
||||
onClick={onOpenQuickActionEditor}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
collapsed={collapsed}
|
||||
icon={<UserRound size={16} />}
|
||||
label="My Agents"
|
||||
onClick={onOpenAgentEditor}
|
||||
/>
|
||||
<SidebarNavItem collapsed={collapsed} icon={<UserRound size={16} />} label="My Agents" />
|
||||
<SidebarNavItem
|
||||
collapsed={collapsed}
|
||||
icon={<FolderKanban size={16} />}
|
||||
@ -72,6 +87,27 @@ export function Sidebar({
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{!collapsed && topAgents.length > 0 && (
|
||||
<section>
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">
|
||||
My Agents
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{topAgents.map(agent => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => onLaunchAgent(agent)}
|
||||
className="rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title={`${agent.name}: ${agent.description}`}
|
||||
>
|
||||
{agent.icon} {agent.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!collapsed && topQuickActions.length > 0 && (
|
||||
<section>
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">
|
||||
@ -146,14 +182,17 @@ function SidebarNavItem({
|
||||
collapsed,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
|
||||
title={label}
|
||||
>
|
||||
|
||||
@ -4,17 +4,23 @@ import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import {
|
||||
addAgent,
|
||||
addQuickAction,
|
||||
addConversation,
|
||||
addMessage,
|
||||
listAgents,
|
||||
listConversations,
|
||||
listQuickActions,
|
||||
seedBuiltinAgents,
|
||||
seedBuiltinQuickActions,
|
||||
updateAgent,
|
||||
updateQuickAction,
|
||||
} from '../lib/db';
|
||||
import type { Conversation, QuickAction } from '../lib/types';
|
||||
import type { Agent, Conversation, QuickAction } from '../lib/types';
|
||||
import { migrateV3ToV4 } from '../lib/migrate';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { QuickActionEditor } from './components/QuickActionEditor';
|
||||
import { AgentEditor } from './components/AgentEditor';
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@ -22,8 +28,10 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [quickActions, setQuickActions] = useState<QuickAction[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [agentEditorOpen, setAgentEditorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('llm-sidebar-state');
|
||||
@ -58,14 +66,61 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
const load = async () => {
|
||||
await migrateV3ToV4();
|
||||
await seedBuiltinQuickActions();
|
||||
await seedBuiltinAgents();
|
||||
const rows = await listConversations({ archived: false, limit: 100 });
|
||||
const qas = await listQuickActions();
|
||||
const ags = await listAgents();
|
||||
setConversations(rows);
|
||||
setQuickActions(qas);
|
||||
setAgents(ags);
|
||||
};
|
||||
void load();
|
||||
}, [pathname]);
|
||||
|
||||
const launchAgent = async (agent: Agent) => {
|
||||
const now = Date.now();
|
||||
const conv: Conversation = {
|
||||
id: crypto.randomUUID(),
|
||||
title: agent.name,
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
systemPrompt: agent.systemPrompt,
|
||||
messageCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
metadata: {
|
||||
totalTokens: 0,
|
||||
totalPrompts: 0,
|
||||
avgTokPerSec: 0,
|
||||
models: agent.model ? [agent.model] : [],
|
||||
},
|
||||
};
|
||||
|
||||
await addConversation(conv);
|
||||
await addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: conv.id,
|
||||
role: 'assistant',
|
||||
content: agent.welcomeMessage || `Hi, I am ${agent.name}. How can I help?`,
|
||||
timestamp: now,
|
||||
model: agent.model,
|
||||
});
|
||||
await updateAgent(agent.id, { conversationCount: agent.conversationCount + 1 });
|
||||
|
||||
setConversations(prev => [conv, ...prev]);
|
||||
setAgents(prev =>
|
||||
prev
|
||||
.map(item =>
|
||||
item.id === agent.id ? { ...item, conversationCount: item.conversationCount + 1 } : item
|
||||
)
|
||||
.sort((a, b) => b.conversationCount - a.conversationCount)
|
||||
);
|
||||
|
||||
router.push(`/c/${conv.id}`);
|
||||
};
|
||||
|
||||
const launchQuickAction = async (qa: QuickAction) => {
|
||||
const now = Date.now();
|
||||
const conv: Conversation = {
|
||||
@ -135,20 +190,26 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
onSelectConversation={id => router.push(`/c/${id}`)}
|
||||
quickActions={quickActions}
|
||||
onLaunchQuickAction={launchQuickAction}
|
||||
agents={agents}
|
||||
onLaunchAgent={launchAgent}
|
||||
onOpenQuickActionEditor={() => setEditorOpen(true)}
|
||||
onOpenAgentEditor={() => setAgentEditorOpen(true)}
|
||||
/>
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
quickActions={quickActions}
|
||||
agents={agents}
|
||||
conversations={conversations}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onSelectQuickAction={launchQuickAction}
|
||||
onSelectAgent={launchAgent}
|
||||
onSelectConversation={conversation => router.push(`/c/${conversation.id}`)}
|
||||
onSelectSystem={action => {
|
||||
if (action === 'mission-control') router.push('/mission-control');
|
||||
if (action === 'settings') {
|
||||
setEditorOpen(true);
|
||||
setAgentEditorOpen(true);
|
||||
}
|
||||
if (action === 'export') {
|
||||
alert('Data export will be wired in a follow-up phase.');
|
||||
@ -165,6 +226,21 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
setQuickActions(qas);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentEditor
|
||||
open={agentEditorOpen}
|
||||
onClose={() => setAgentEditorOpen(false)}
|
||||
onSave={async agent => {
|
||||
const existing = agents.find(item => item.id === agent.id);
|
||||
if (existing) {
|
||||
await updateAgent(agent.id, agent);
|
||||
} else {
|
||||
await addAgent(agent);
|
||||
}
|
||||
const ags = await listAgents();
|
||||
setAgents(ags);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
130
__LOCAL_LLMs/dashboard/src/app/lib/agents.ts
Normal file
130
__LOCAL_LLMs/dashboard/src/app/lib/agents.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { Agent } from './types';
|
||||
|
||||
function agent(
|
||||
id: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
model: string,
|
||||
description: string,
|
||||
systemPrompt: string,
|
||||
welcomeMessage: string,
|
||||
examplePrompts: string[]
|
||||
): Agent {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
description,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools: ['file_read'],
|
||||
welcomeMessage,
|
||||
examplePrompts,
|
||||
builtin: true,
|
||||
conversationCount: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export const BUILTIN_AGENTS: Agent[] = [
|
||||
agent(
|
||||
'agent-code-pilot',
|
||||
'Code Pilot',
|
||||
'👨✈️',
|
||||
'coding',
|
||||
'Full-stack coding copilot',
|
||||
'You are Code Pilot, a senior software engineer. Prioritize correctness, maintainability, and clear implementation details.',
|
||||
'Welcome! Share your code or requirement and I will help you implement it safely.',
|
||||
['Review this function', 'Generate endpoint with validation', 'Refactor for readability']
|
||||
),
|
||||
agent(
|
||||
'agent-writing-editor',
|
||||
'Writing Editor',
|
||||
'📝',
|
||||
'chat',
|
||||
'Improve writing quality and tone',
|
||||
'You are a writing editor. Improve clarity, tone, flow, and grammar while preserving intent.',
|
||||
'Welcome! Paste text and I will rewrite it for clarity and impact.',
|
||||
['Rewrite this email', 'Proofread this paragraph', 'Make this concise']
|
||||
),
|
||||
agent(
|
||||
'agent-deep-thinker',
|
||||
'Deep Thinker',
|
||||
'🧠',
|
||||
'reasoning',
|
||||
'Step-by-step reasoning specialist',
|
||||
'You are a deliberate reasoning assistant. Think step by step and call out tradeoffs before concluding.',
|
||||
'Let’s reason through the problem together, step by step.',
|
||||
['Analyze this architecture decision', 'Compare these options', 'Root-cause this issue']
|
||||
),
|
||||
agent(
|
||||
'agent-shell-wizard',
|
||||
'Shell Wizard',
|
||||
'💻',
|
||||
'coding',
|
||||
'macOS/zsh command expert',
|
||||
'You are a shell command expert for macOS and zsh. Always flag destructive commands and explain flags.',
|
||||
'Need a command? Tell me the task and I will generate a safe command.',
|
||||
['Create archive of logs', 'Find large files', 'Bulk rename files']
|
||||
),
|
||||
agent(
|
||||
'agent-data-analyst',
|
||||
'Data Analyst',
|
||||
'📊',
|
||||
'reasoning',
|
||||
'Data and SQL analysis specialist',
|
||||
'You are a data analyst. Provide structured analysis, assumptions, and recommended actions.',
|
||||
'Share data or a question and I will analyze it clearly.',
|
||||
['Write this SQL query', 'Analyze this CSV trend', 'Summarize metrics']
|
||||
),
|
||||
agent(
|
||||
'agent-creative-writer',
|
||||
'Creative Writer',
|
||||
'🎨',
|
||||
'chat',
|
||||
'Creative brainstorming and storytelling',
|
||||
'You are a creative writer. Produce vivid, engaging output and offer multiple variants.',
|
||||
'Let’s create something compelling together.',
|
||||
['Write a short story', 'Brainstorm campaign ideas', 'Craft a product narrative']
|
||||
),
|
||||
agent(
|
||||
'agent-rubber-duck',
|
||||
'Rubber Duck',
|
||||
'🦆',
|
||||
'fast',
|
||||
'Socratic debugging partner',
|
||||
'You are a rubber duck. Ask short clarifying questions that help the user find the answer.',
|
||||
'I will ask focused questions to help you reason through the issue.',
|
||||
['Help me debug this bug', 'Ask me questions about this design', 'Unblock my implementation']
|
||||
),
|
||||
agent(
|
||||
'agent-translator',
|
||||
'Translator',
|
||||
'🌐',
|
||||
'chat',
|
||||
'Accurate multilingual translator',
|
||||
'You are a translator. Preserve meaning and tone with concise, natural phrasing.',
|
||||
'I can translate and localize text with tone awareness.',
|
||||
['Translate this to Spanish', 'Localize for UK English', 'Simplify this for global audience']
|
||||
),
|
||||
agent(
|
||||
'agent-api-designer',
|
||||
'API Designer',
|
||||
'🔌',
|
||||
'coding',
|
||||
'REST/GraphQL API design expert',
|
||||
'You are an API designer. Produce clean contracts, validation rules, and error models.',
|
||||
'Tell me your API use case and I will design robust endpoints.',
|
||||
['Design REST endpoints', 'Create OpenAPI schema', 'Design pagination strategy']
|
||||
),
|
||||
agent(
|
||||
'agent-security-auditor',
|
||||
'Security Auditor',
|
||||
'🔒',
|
||||
'reasoning',
|
||||
'Application security reviewer',
|
||||
'You are a security auditor. Identify vulnerabilities, severity, and practical remediations.',
|
||||
'Share code or architecture and I will run a security review.',
|
||||
['Review auth flow security', 'Find injection risks', 'Audit secret handling']
|
||||
),
|
||||
];
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
ScheduledTask,
|
||||
} from './types';
|
||||
import { BUILTIN_QUICK_ACTIONS } from './quick-actions';
|
||||
import { BUILTIN_AGENTS } from './agents';
|
||||
|
||||
interface TaskRunRecord {
|
||||
id?: number;
|
||||
@ -315,3 +316,75 @@ export async function importQuickActions(actions: QuickAction[]): Promise<number
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
export async function addAgent(agent: Agent): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('agents', agent);
|
||||
}
|
||||
|
||||
export async function getAgent(id: string): Promise<Agent | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('agents', id);
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<Agent[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAll('agents');
|
||||
rows.sort((a, b) => {
|
||||
if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
|
||||
return b.conversationCount - a.conversationCount;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function updateAgent(id: string, partial: Partial<Agent>): Promise<Agent | null> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('agents', id);
|
||||
if (!existing) return null;
|
||||
const updated: Agent = { ...existing, ...partial, id };
|
||||
await db.put('agents', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('agents', id);
|
||||
}
|
||||
|
||||
export async function seedBuiltinAgents(): Promise<number> {
|
||||
const seeded = typeof window !== 'undefined' ? localStorage.getItem('llm-agents-seeded') : 'true';
|
||||
if (seeded === 'true') return 0;
|
||||
|
||||
const db = await getDb();
|
||||
const tx = db.transaction('agents', 'readwrite');
|
||||
let inserted = 0;
|
||||
for (const item of BUILTIN_AGENTS) {
|
||||
const existing = await tx.store.get(item.id);
|
||||
if (!existing) {
|
||||
await tx.store.put(item);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
await tx.done;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('llm-agents-seeded', 'true');
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export async function exportAgents(): Promise<Agent[]> {
|
||||
const rows = await listAgents();
|
||||
return rows.filter(row => !row.builtin);
|
||||
}
|
||||
|
||||
export async function importAgents(agents: Agent[]): Promise<number> {
|
||||
const db = await getDb();
|
||||
let imported = 0;
|
||||
for (const agent of agents) {
|
||||
const existing = await db.get('agents', agent.id);
|
||||
if (existing) continue;
|
||||
await db.put('agents', agent);
|
||||
imported += 1;
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user