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:
saravanakumardb1 2026-02-20 00:26:46 -08:00
parent f289099461
commit d18b695029
9 changed files with 604 additions and 10 deletions

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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();

View File

@ -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>
);

View File

@ -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}

View File

@ -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}
>

View File

@ -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>
);
}

View 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.',
'Lets 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.',
'Lets 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']
),
];

View File

@ -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;
}