feat(local-llm): Phase B quick actions + command palette (B1-B6)
- add fuse.js dependency and command palette modal (Cmd+K) - add built-in quick actions library (30 templates across categories) - add quick action CRUD + seeding + import/export helpers in db layer - seed quick actions on workspace load and list top actions in sidebar - implement quick action launcher -> creates preconfigured conversation - add custom quick action editor modal for creating/editing actions - wire command palette system actions and conversation navigation - support passing QA template into conversation input via query param
This commit is contained in:
parent
1335d47869
commit
7ae92da16e
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
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 { ConversationView } from '../../components/ConversationView';
|
||||
|
||||
export default function ConversationPage({ params }: { params: { id: string } }) {
|
||||
const searchParams = useSearchParams();
|
||||
const [conversation, setConversation] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -47,5 +49,11 @@ export default function ConversationPage({ params }: { params: { id: string } })
|
||||
);
|
||||
}
|
||||
|
||||
return <ConversationView conversation={conversation} initialMessages={messages} />;
|
||||
return (
|
||||
<ConversationView
|
||||
conversation={conversation}
|
||||
initialMessages={messages}
|
||||
initialTemplate={searchParams.get('template') || ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import type { Conversation, QuickAction } from '../../lib/types';
|
||||
|
||||
type CommandItem =
|
||||
| {
|
||||
type: 'qa';
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
payload: QuickAction;
|
||||
}
|
||||
| {
|
||||
type: 'conversation';
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
payload: Conversation;
|
||||
}
|
||||
| {
|
||||
type: 'system';
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
action: 'mission-control' | 'settings' | 'export';
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
quickActions: QuickAction[];
|
||||
conversations: Conversation[];
|
||||
onClose: () => void;
|
||||
onSelectQuickAction: (qa: QuickAction) => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onSelectSystem: (action: 'mission-control' | 'settings' | 'export') => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
quickActions,
|
||||
conversations,
|
||||
onClose,
|
||||
onSelectQuickAction,
|
||||
onSelectConversation,
|
||||
onSelectSystem,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const items = useMemo<CommandItem[]>(() => {
|
||||
const qaItems: CommandItem[] = quickActions.map(qa => ({
|
||||
type: 'qa',
|
||||
id: qa.id,
|
||||
name: qa.name,
|
||||
description: qa.description,
|
||||
icon: qa.icon,
|
||||
payload: qa,
|
||||
}));
|
||||
|
||||
const convItems: CommandItem[] = conversations.slice(0, 20).map(conv => ({
|
||||
type: 'conversation',
|
||||
id: conv.id,
|
||||
name: conv.title,
|
||||
description: conv.model || 'Conversation',
|
||||
icon: '💬',
|
||||
payload: conv,
|
||||
}));
|
||||
|
||||
const systemItems: CommandItem[] = [
|
||||
{
|
||||
type: 'system',
|
||||
id: 'sys-mission-control',
|
||||
name: 'Mission Control',
|
||||
description: 'Open model and system dashboard',
|
||||
icon: '🎛️',
|
||||
action: 'mission-control',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
id: 'sys-settings',
|
||||
name: 'Settings',
|
||||
description: 'Open settings panel',
|
||||
icon: '⚙️',
|
||||
action: 'settings',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
id: 'sys-export',
|
||||
name: 'Export Data',
|
||||
description: 'Export workspace data',
|
||||
icon: '📦',
|
||||
action: 'export',
|
||||
},
|
||||
];
|
||||
|
||||
return [...qaItems, ...convItems, ...systemItems];
|
||||
}, [quickActions, conversations]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return items.slice(0, 15);
|
||||
const fuse = new Fuse(items, {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
includeScore: true,
|
||||
});
|
||||
return fuse
|
||||
.search(query)
|
||||
.map(r => r.item)
|
||||
.slice(0, 15);
|
||||
}, [items, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-16 w-full max-w-2xl rounded-lg border border-white/10 bg-[var(--surface-card)] p-3 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search actions, conversations, commands..."
|
||||
className="mb-3 w-full rounded-md border border-white/10 bg-[var(--surface-muted)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="max-h-[60vh] space-y-1 overflow-auto">
|
||||
{results.map(item => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.type === 'qa') onSelectQuickAction(item.payload);
|
||||
if (item.type === 'conversation') onSelectConversation(item.payload);
|
||||
if (item.type === 'system') onSelectSystem(item.action);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left hover:bg-white/5"
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-[var(--text-primary)]">{item.name}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{item.description}</p>
|
||||
</div>
|
||||
<span className="rounded border border-white/10 px-2 py-0.5 text-[10px] uppercase text-[var(--text-tertiary)]">
|
||||
{item.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-sm text-[var(--text-tertiary)]">No results</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,12 +12,14 @@ import { estimateTokens, getModelContextWindow } from '../../lib/format';
|
||||
interface ConversationViewProps {
|
||||
conversation: Conversation;
|
||||
initialMessages: Message[];
|
||||
initialTemplate?: string;
|
||||
onConversationUpdated?: (next: Conversation) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
conversation,
|
||||
initialMessages,
|
||||
initialTemplate,
|
||||
onConversationUpdated,
|
||||
}: ConversationViewProps) {
|
||||
const [title, setTitle] = useState(conversation.title);
|
||||
@ -269,6 +271,7 @@ export function ConversationView({
|
||||
streaming={streaming}
|
||||
onCancel={onCancel}
|
||||
disabled={!selectedModel}
|
||||
initialText={initialTemplate || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { QuickAction } from '../../lib/types';
|
||||
|
||||
interface QuickActionEditorProps {
|
||||
open: boolean;
|
||||
editing?: QuickAction | null;
|
||||
onClose: () => void;
|
||||
onSave: (action: QuickAction) => Promise<void>;
|
||||
}
|
||||
|
||||
const CATEGORIES: QuickAction['category'][] = [
|
||||
'code',
|
||||
'writing',
|
||||
'analysis',
|
||||
'creative',
|
||||
'devops',
|
||||
'custom',
|
||||
];
|
||||
|
||||
const MODEL_HINTS = ['fast', 'coding', 'reasoning', 'chat', 'vision'];
|
||||
|
||||
export function QuickActionEditor({ open, editing, onClose, onSave }: QuickActionEditorProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [icon, setIcon] = useState('✨');
|
||||
const [category, setCategory] = useState<QuickAction['category']>('custom');
|
||||
const [description, setDescription] = useState('');
|
||||
const [modelHint, setModelHint] = useState('chat');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [userTemplate, setUserTemplate] = useState('');
|
||||
const [hotkey, setHotkey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setName(editing.name);
|
||||
setIcon(editing.icon);
|
||||
setCategory(editing.category);
|
||||
setDescription(editing.description);
|
||||
setModelHint(editing.modelHint);
|
||||
setSystemPrompt(editing.systemPrompt);
|
||||
setUserTemplate(editing.userTemplate);
|
||||
setHotkey(editing.hotkey || '');
|
||||
return;
|
||||
}
|
||||
|
||||
setName('');
|
||||
setIcon('✨');
|
||||
setCategory('custom');
|
||||
setDescription('');
|
||||
setModelHint('chat');
|
||||
setSystemPrompt('');
|
||||
setUserTemplate('');
|
||||
setHotkey('');
|
||||
}, [open, editing]);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return name.trim().length > 0 && systemPrompt.trim().length > 0;
|
||||
}, [name, systemPrompt]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl 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 Quick Action' : 'Create Quick Action'}
|
||||
</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)]">
|
||||
Category
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value as QuickAction['category'])}
|
||||
className="mt-1 w-full rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
{CATEGORIES.map(item => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
Model Hint
|
||||
<select
|
||||
value={modelHint}
|
||||
onChange={e => setModelHint(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"
|
||||
>
|
||||
{MODEL_HINTS.map(item => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="md:col-span-2 text-sm text-[var(--text-secondary)]">
|
||||
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="md:col-span-2 text-sm text-[var(--text-secondary)]">
|
||||
System Prompt
|
||||
<textarea
|
||||
rows={5}
|
||||
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="md:col-span-2 text-sm text-[var(--text-secondary)]">
|
||||
User Template
|
||||
<textarea
|
||||
rows={4}
|
||||
value={userTemplate}
|
||||
onChange={e => setUserTemplate(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)]">
|
||||
Hotkey (optional)
|
||||
<input
|
||||
value={hotkey}
|
||||
onChange={e => setHotkey(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>
|
||||
</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 action: QuickAction = {
|
||||
id: editing?.id || crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || '✨',
|
||||
category,
|
||||
description: description.trim(),
|
||||
modelHint,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
userTemplate: userTemplate.trim(),
|
||||
builtin: false,
|
||||
hotkey: hotkey.trim() || undefined,
|
||||
usageCount: editing?.usageCount ?? 0,
|
||||
lastUsed: editing?.lastUsed,
|
||||
};
|
||||
await onSave(action);
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,7 +11,7 @@ import {
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import type { Conversation } from '../../lib/types';
|
||||
import type { Conversation, QuickAction } from '../../lib/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ConversationList } from './ConversationList';
|
||||
|
||||
@ -21,6 +21,8 @@ interface SidebarProps {
|
||||
onNewConversation: () => void;
|
||||
activeConversationId?: string;
|
||||
onSelectConversation: (id: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
onLaunchQuickAction: (qa: QuickAction) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@ -29,6 +31,8 @@ export function Sidebar({
|
||||
onNewConversation,
|
||||
activeConversationId,
|
||||
onSelectConversation,
|
||||
quickActions,
|
||||
onLaunchQuickAction,
|
||||
}: SidebarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
@ -36,6 +40,7 @@ export function Sidebar({
|
||||
if (!q) return conversations;
|
||||
return conversations.filter(c => c.title.toLowerCase().includes(q));
|
||||
}, [conversations, query]);
|
||||
const topQuickActions = useMemo(() => quickActions.slice(0, 5), [quickActions]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@ -67,6 +72,27 @@ export function Sidebar({
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{!collapsed && topQuickActions.length > 0 && (
|
||||
<section>
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">
|
||||
Frequently-used Quick Actions
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{topQuickActions.map(qa => (
|
||||
<button
|
||||
key={qa.id}
|
||||
type="button"
|
||||
onClick={() => onLaunchQuickAction(qa)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<span>{qa.icon}</span>
|
||||
<span className="truncate">{qa.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
|
||||
@ -3,15 +3,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { addConversation, listConversations } from '../lib/db';
|
||||
import type { Conversation } from '../lib/types';
|
||||
import {
|
||||
addQuickAction,
|
||||
addConversation,
|
||||
listConversations,
|
||||
listQuickActions,
|
||||
seedBuiltinQuickActions,
|
||||
updateQuickAction,
|
||||
} from '../lib/db';
|
||||
import type { Conversation, QuickAction } from '../lib/types';
|
||||
import { migrateV3ToV4 } from '../lib/migrate';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { QuickActionEditor } from './components/QuickActionEditor';
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [quickActions, setQuickActions] = useState<QuickAction[]>([]);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('llm-sidebar-state');
|
||||
@ -28,6 +40,15 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setPaletteOpen(true);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setPaletteOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
@ -36,12 +57,49 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
await migrateV3ToV4();
|
||||
await seedBuiltinQuickActions();
|
||||
const rows = await listConversations({ archived: false, limit: 100 });
|
||||
const qas = await listQuickActions();
|
||||
setConversations(rows);
|
||||
setQuickActions(qas);
|
||||
};
|
||||
void load();
|
||||
}, [pathname]);
|
||||
|
||||
const launchQuickAction = async (qa: QuickAction) => {
|
||||
const now = Date.now();
|
||||
const conv: Conversation = {
|
||||
id: crypto.randomUUID(),
|
||||
title: qa.name,
|
||||
model: qa.modelHint,
|
||||
systemPrompt: qa.systemPrompt,
|
||||
messageCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
metadata: {
|
||||
totalTokens: 0,
|
||||
totalPrompts: 0,
|
||||
avgTokPerSec: 0,
|
||||
models: qa.modelHint ? [qa.modelHint] : [],
|
||||
},
|
||||
};
|
||||
|
||||
await addConversation(conv);
|
||||
await updateQuickAction(qa.id, { usageCount: qa.usageCount + 1, lastUsed: now });
|
||||
setConversations(prev => [conv, ...prev]);
|
||||
setQuickActions(prev =>
|
||||
prev
|
||||
.map(item =>
|
||||
item.id === qa.id ? { ...item, usageCount: item.usageCount + 1, lastUsed: now } : item
|
||||
)
|
||||
.sort((a, b) => b.usageCount - a.usageCount)
|
||||
);
|
||||
const template = encodeURIComponent(qa.userTemplate);
|
||||
router.push(`/c/${conv.id}?template=${template}`);
|
||||
};
|
||||
|
||||
const onNewConversation = async () => {
|
||||
const now = Date.now();
|
||||
const conv: Conversation = {
|
||||
@ -75,8 +133,38 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
onNewConversation={onNewConversation}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelectConversation={id => router.push(`/c/${id}`)}
|
||||
quickActions={quickActions}
|
||||
onLaunchQuickAction={launchQuickAction}
|
||||
/>
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
quickActions={quickActions}
|
||||
conversations={conversations}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onSelectQuickAction={launchQuickAction}
|
||||
onSelectConversation={conversation => router.push(`/c/${conversation.id}`)}
|
||||
onSelectSystem={action => {
|
||||
if (action === 'mission-control') router.push('/mission-control');
|
||||
if (action === 'settings') {
|
||||
setEditorOpen(true);
|
||||
}
|
||||
if (action === 'export') {
|
||||
alert('Data export will be wired in a follow-up phase.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<QuickActionEditor
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={async action => {
|
||||
await addQuickAction(action);
|
||||
const qas = await listQuickActions();
|
||||
setQuickActions(qas);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
QuickAction,
|
||||
ScheduledTask,
|
||||
} from './types';
|
||||
import { BUILTIN_QUICK_ACTIONS } from './quick-actions';
|
||||
|
||||
interface TaskRunRecord {
|
||||
id?: number;
|
||||
@ -234,3 +235,83 @@ export async function countMessages(conversationId: string): Promise<number> {
|
||||
const range = IDBKeyRange.bound([conversationId, 0], [conversationId, Number.MAX_SAFE_INTEGER]);
|
||||
return db.countFromIndex('messages', 'by-conversation-ts', range);
|
||||
}
|
||||
|
||||
export async function addQuickAction(action: QuickAction): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('quickActions', action);
|
||||
}
|
||||
|
||||
export async function getQuickAction(id: string): Promise<QuickAction | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('quickActions', id);
|
||||
}
|
||||
|
||||
export async function listQuickActions(category?: QuickAction['category']): Promise<QuickAction[]> {
|
||||
const db = await getDb();
|
||||
let rows = await db.getAll('quickActions');
|
||||
if (category) {
|
||||
rows = rows.filter(row => row.category === category);
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
|
||||
return b.usageCount - a.usageCount;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function updateQuickAction(
|
||||
id: string,
|
||||
partial: Partial<QuickAction>
|
||||
): Promise<QuickAction | null> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('quickActions', id);
|
||||
if (!existing) return null;
|
||||
const updated: QuickAction = { ...existing, ...partial, id };
|
||||
await db.put('quickActions', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteQuickAction(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('quickActions', id);
|
||||
}
|
||||
|
||||
export async function seedBuiltinQuickActions(): Promise<number> {
|
||||
const seeded = typeof window !== 'undefined' ? localStorage.getItem('llm-qa-seeded') : 'true';
|
||||
if (seeded === 'true') return 0;
|
||||
|
||||
const db = await getDb();
|
||||
const tx = db.transaction('quickActions', 'readwrite');
|
||||
let inserted = 0;
|
||||
|
||||
for (const action of BUILTIN_QUICK_ACTIONS) {
|
||||
const existing = await tx.store.get(action.id);
|
||||
if (!existing) {
|
||||
await tx.store.put(action);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.done;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('llm-qa-seeded', 'true');
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export async function exportQuickActions(): Promise<QuickAction[]> {
|
||||
const rows = await listQuickActions();
|
||||
return rows.filter(row => !row.builtin);
|
||||
}
|
||||
|
||||
export async function importQuickActions(actions: QuickAction[]): Promise<number> {
|
||||
const db = await getDb();
|
||||
let imported = 0;
|
||||
for (const action of actions) {
|
||||
const existing = await db.get('quickActions', action.id);
|
||||
if (existing) continue;
|
||||
await db.put('quickActions', action);
|
||||
imported += 1;
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
337
__LOCAL_LLMs/dashboard/src/app/lib/quick-actions.ts
Normal file
337
__LOCAL_LLMs/dashboard/src/app/lib/quick-actions.ts
Normal file
@ -0,0 +1,337 @@
|
||||
import type { QuickAction } from './types';
|
||||
|
||||
function qa(
|
||||
id: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
category: QuickAction['category'],
|
||||
modelHint: string,
|
||||
description: string,
|
||||
systemPrompt: string,
|
||||
userTemplate: string
|
||||
): QuickAction {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
category,
|
||||
modelHint,
|
||||
description,
|
||||
systemPrompt,
|
||||
userTemplate,
|
||||
builtin: true,
|
||||
usageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const BUILTIN_QUICK_ACTIONS: QuickAction[] = [
|
||||
// Code (8)
|
||||
qa(
|
||||
'builtin-code-review',
|
||||
'Code Review',
|
||||
'🔍',
|
||||
'code',
|
||||
'reasoning',
|
||||
'Review code for bugs, perf, and security',
|
||||
'You are a senior code reviewer. Find bugs, security issues, performance issues, and maintainability improvements. Reference concrete code lines and provide fixes.',
|
||||
'Review this code:\n\n{paste code}'
|
||||
),
|
||||
qa(
|
||||
'builtin-explain-code',
|
||||
'Explain Code',
|
||||
'📖',
|
||||
'code',
|
||||
'fast',
|
||||
'Explain what code does',
|
||||
'Explain the provided code in clear simple language. Break down each section and call out important patterns.',
|
||||
'Explain this code:\n\n{paste code}'
|
||||
),
|
||||
qa(
|
||||
'builtin-write-tests',
|
||||
'Write Tests',
|
||||
'🧪',
|
||||
'code',
|
||||
'coding',
|
||||
'Generate unit tests',
|
||||
'You are a test engineer. Generate comprehensive unit tests with edge cases and clear test names.',
|
||||
'Write tests for:\n\n{paste code}'
|
||||
),
|
||||
qa(
|
||||
'builtin-debug-error',
|
||||
'Debug Error',
|
||||
'🐛',
|
||||
'code',
|
||||
'reasoning',
|
||||
'Root-cause debugging assistant',
|
||||
'You are a debugging specialist. Identify root cause, explain why, and provide corrected code.',
|
||||
'Error:\n{paste error}\n\nCode:\n{paste code}'
|
||||
),
|
||||
qa(
|
||||
'builtin-refactor',
|
||||
'Refactor',
|
||||
'♻️',
|
||||
'code',
|
||||
'coding',
|
||||
'Refactor for readability and performance',
|
||||
'Refactor the code for readability, maintainability, and performance without changing behavior.',
|
||||
'Refactor this:\n\n{paste code}'
|
||||
),
|
||||
qa(
|
||||
'builtin-generate-code',
|
||||
'Generate Code',
|
||||
'⚡',
|
||||
'code',
|
||||
'coding',
|
||||
'Generate production-ready code',
|
||||
'Generate robust production-ready code with clear comments only where needed.',
|
||||
'Build this:\n\n{describe requirement}'
|
||||
),
|
||||
qa(
|
||||
'builtin-regex-builder',
|
||||
'Regex Builder',
|
||||
'🔤',
|
||||
'code',
|
||||
'fast',
|
||||
'Build and explain regex patterns',
|
||||
'You are a regex expert. Build the requested regex and explain each part with test examples.',
|
||||
'Create regex for:\n\n{describe pattern}'
|
||||
),
|
||||
qa(
|
||||
'builtin-sql-query',
|
||||
'SQL Query',
|
||||
'🗄️',
|
||||
'code',
|
||||
'coding',
|
||||
'Generate optimized SQL queries',
|
||||
'You are a SQL expert. Generate efficient SQL and explain indexing/performance implications.',
|
||||
'Write SQL for:\n\n{describe}\n\nSchema:\n{paste schema}'
|
||||
),
|
||||
|
||||
// Writing (6)
|
||||
qa(
|
||||
'builtin-summarize',
|
||||
'Summarize',
|
||||
'📝',
|
||||
'writing',
|
||||
'fast',
|
||||
'Summarize content into key points',
|
||||
'Summarize the content clearly with concise bullet points and key takeaways.',
|
||||
'Summarize:\n\n{paste text}'
|
||||
),
|
||||
qa(
|
||||
'builtin-rewrite',
|
||||
'Rewrite',
|
||||
'✏️',
|
||||
'writing',
|
||||
'chat',
|
||||
'Rewrite text for clarity/tone',
|
||||
'Rewrite while preserving meaning and improving clarity, flow, and tone.',
|
||||
'Rewrite this:\n\n{paste text}'
|
||||
),
|
||||
qa(
|
||||
'builtin-translate',
|
||||
'Translate',
|
||||
'🌐',
|
||||
'writing',
|
||||
'chat',
|
||||
'Translate while preserving nuance',
|
||||
'Translate accurately preserving tone and intent. Provide alternate phrasing if ambiguous.',
|
||||
'Translate to {language}:\n\n{paste text}'
|
||||
),
|
||||
qa(
|
||||
'builtin-proofread',
|
||||
'Proofread',
|
||||
'✅',
|
||||
'writing',
|
||||
'fast',
|
||||
'Proofread grammar and style',
|
||||
'Proofread grammar, spelling, punctuation, and style. Return corrected version and notable issues.',
|
||||
'Proofread:\n\n{paste text}'
|
||||
),
|
||||
qa(
|
||||
'builtin-draft-email',
|
||||
'Draft Email',
|
||||
'📧',
|
||||
'writing',
|
||||
'chat',
|
||||
'Draft professional email',
|
||||
'Draft concise professional emails with clear subject, action items, and tone.',
|
||||
'Draft email to {recipient} about {topic}.'
|
||||
),
|
||||
qa(
|
||||
'builtin-tech-doc',
|
||||
'Technical Docs',
|
||||
'📄',
|
||||
'writing',
|
||||
'coding',
|
||||
'Write technical documentation',
|
||||
'Write clear technical documentation with examples, edge cases, and concise structure.',
|
||||
'Document this:\n\n{paste code or feature}'
|
||||
),
|
||||
|
||||
// Analysis (6)
|
||||
qa(
|
||||
'builtin-deep-think',
|
||||
'Deep Think',
|
||||
'🧠',
|
||||
'analysis',
|
||||
'reasoning',
|
||||
'Step-by-step deep analysis',
|
||||
'Think carefully and step-by-step. Compare alternatives and provide recommendation.',
|
||||
'Think through:\n\n{problem}'
|
||||
),
|
||||
qa(
|
||||
'builtin-compare-options',
|
||||
'Compare Options',
|
||||
'⚖️',
|
||||
'analysis',
|
||||
'reasoning',
|
||||
'Compare alternatives with trade-offs',
|
||||
'Compare options objectively with pros/cons matrix and recommendation.',
|
||||
'Compare:\n\n{option A}\nvs\n{option B}'
|
||||
),
|
||||
qa(
|
||||
'builtin-eli5',
|
||||
'Explain Like I’m 5',
|
||||
'👶',
|
||||
'analysis',
|
||||
'chat',
|
||||
'Explain complex ideas simply',
|
||||
'Explain complex concepts to a beginner using analogies and plain language.',
|
||||
'Explain simply:\n\n{topic}'
|
||||
),
|
||||
qa(
|
||||
'builtin-fact-check',
|
||||
'Fact Check',
|
||||
'🔎',
|
||||
'analysis',
|
||||
'reasoning',
|
||||
'Check claims and caveats',
|
||||
'Evaluate factual claims. Separate confirmed facts, likely true, and uncertain points.',
|
||||
'Fact-check:\n\n{claim}'
|
||||
),
|
||||
qa(
|
||||
'builtin-risk-review',
|
||||
'Risk Review',
|
||||
'⚠️',
|
||||
'analysis',
|
||||
'reasoning',
|
||||
'Analyze implementation risks',
|
||||
'Identify technical, security, and operational risks. Provide mitigations and severity.',
|
||||
'Review risks for:\n\n{proposal}'
|
||||
),
|
||||
qa(
|
||||
'builtin-root-cause',
|
||||
'Root Cause Analysis',
|
||||
'🧩',
|
||||
'analysis',
|
||||
'reasoning',
|
||||
'Find root causes systematically',
|
||||
'Perform root cause analysis and provide 5 whys plus actionable fixes.',
|
||||
'Analyze root cause:\n\n{incident description}'
|
||||
),
|
||||
|
||||
// Creative (5)
|
||||
qa(
|
||||
'builtin-brainstorm',
|
||||
'Brainstorm',
|
||||
'💡',
|
||||
'creative',
|
||||
'chat',
|
||||
'Generate diverse ideas',
|
||||
'Generate diverse ideas including practical and unconventional options.',
|
||||
'Brainstorm ideas for:\n\n{topic}'
|
||||
),
|
||||
qa(
|
||||
'builtin-role-play',
|
||||
'Role Play',
|
||||
'🎭',
|
||||
'creative',
|
||||
'chat',
|
||||
'Role-play scenario',
|
||||
'Stay in character and respond consistently with persona and context.',
|
||||
'Role-play:\n\n{scenario}'
|
||||
),
|
||||
qa(
|
||||
'builtin-story-writer',
|
||||
'Story Writer',
|
||||
'📚',
|
||||
'creative',
|
||||
'chat',
|
||||
'Write story content',
|
||||
'Write engaging story content with strong pacing and vivid details.',
|
||||
'Write a story about:\n\n{premise}'
|
||||
),
|
||||
qa(
|
||||
'builtin-name-generator',
|
||||
'Name Generator',
|
||||
'🏷️',
|
||||
'creative',
|
||||
'fast',
|
||||
'Generate names with rationale',
|
||||
'Generate creative names and explain why each name works.',
|
||||
'Generate names for:\n\n{product or concept}'
|
||||
),
|
||||
qa(
|
||||
'builtin-pitch-maker',
|
||||
'Pitch Maker',
|
||||
'🎤',
|
||||
'creative',
|
||||
'chat',
|
||||
'Create a concise pitch',
|
||||
'Craft a concise compelling pitch with hook, value, and call to action.',
|
||||
'Pitch this idea:\n\n{idea}'
|
||||
),
|
||||
|
||||
// DevOps (5)
|
||||
qa(
|
||||
'builtin-shell-command',
|
||||
'Shell Command',
|
||||
'💻',
|
||||
'devops',
|
||||
'coding',
|
||||
'Generate safe shell commands',
|
||||
'Generate safe macOS zsh commands. Warn explicitly for destructive commands.',
|
||||
'Write command to:\n\n{task}'
|
||||
),
|
||||
qa(
|
||||
'builtin-docker-k8s',
|
||||
'Docker/K8s',
|
||||
'🐳',
|
||||
'devops',
|
||||
'coding',
|
||||
'Generate Docker/K8s configs',
|
||||
'Generate secure Docker/K8s config with sensible defaults and brief rationale.',
|
||||
'Generate config for:\n\n{service requirement}'
|
||||
),
|
||||
qa(
|
||||
'builtin-git-help',
|
||||
'Git Help',
|
||||
'🌿',
|
||||
'devops',
|
||||
'fast',
|
||||
'Provide git commands for scenarios',
|
||||
'Provide exact git commands and explain each command briefly.',
|
||||
'Help with git scenario:\n\n{scenario}'
|
||||
),
|
||||
qa(
|
||||
'builtin-json-yaml',
|
||||
'JSON/YAML Tool',
|
||||
'📋',
|
||||
'devops',
|
||||
'fast',
|
||||
'Convert/validate data formats',
|
||||
'Convert and validate JSON/YAML with clear errors and fixed output.',
|
||||
'Convert/validate:\n\n{data}'
|
||||
),
|
||||
qa(
|
||||
'builtin-release-notes',
|
||||
'Release Notes',
|
||||
'🚀',
|
||||
'devops',
|
||||
'chat',
|
||||
'Generate release notes from changes',
|
||||
'Generate concise release notes grouped by feature/fix/chore.',
|
||||
'Create release notes from:\n\n{change log}'
|
||||
),
|
||||
];
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -71,6 +71,9 @@ importers:
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: ^15.5.13
|
||||
version: 15.5.13
|
||||
fuse.js:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
@ -3983,6 +3986,13 @@ packages:
|
||||
integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==,
|
||||
}
|
||||
|
||||
fuse.js@7.1.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==,
|
||||
}
|
||||
engines: { node: '>=10' }
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution:
|
||||
{
|
||||
@ -9581,6 +9591,8 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuse.js@7.1.0: {}
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user