feat(agents): implement agents feature (v1)
Adds agents feature with the following capabilities: - Agent identity: name, description - System prompt reference from Prompt Library (promptId) - Tool set: subset of available tools (enabledToolNames) - Optional preferred model - CRUD operations with IndexedDB storage (schema v7) - Project-agent relationships (many-to-many via junction table) - Per-chat agent selection via AgentSelector component - Settings UI via AgentsTab in Settings page Integration: - Agent tools filter LLM tool calls via getToolDefinitionsForAgent() - Agent prompt integrates with prompt resolution (priority 3) - AgentSelector dropdown in chat UI (opens upward) Tests: - 22 storage layer tests - 22 state management tests - 7 tool integration tests - 9 prompt resolution tests - 14 E2E tests Closes #7
This commit is contained in:
278
frontend/e2e/agents.spec.ts
Normal file
278
frontend/e2e/agents.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* E2E tests for Agents feature
|
||||
*
|
||||
* Tests the agents UI in settings and chat integration
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Agents', () => {
|
||||
test('settings page has agents tab', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Should show agents tab content - use exact match for the main heading
|
||||
await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('agents tab shows empty state initially', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Should show empty state message
|
||||
await expect(page.getByRole('heading', { name: 'No agents yet' })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('has create agent button', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Should have create button in the header (not the empty state button)
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await expect(createButton).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can open create agent dialog', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Click create button (the one in the header)
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
// Dialog should appear with form fields
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByLabel('Name *')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create new agent', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Open create dialog
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
// Wait for dialog
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill in agent details
|
||||
await page.getByLabel('Name *').fill('Test Agent');
|
||||
await page.getByLabel('Description').fill('A test agent for E2E testing');
|
||||
|
||||
// Submit the form - use the submit button inside the dialog
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
// Dialog should close and agent should appear in the list
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('heading', { name: 'Test Agent' })).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can edit existing agent', async ({ page }) => {
|
||||
// First create an agent
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Edit Me Agent');
|
||||
await page.getByLabel('Description').fill('Will be edited');
|
||||
|
||||
// Submit via dialog button
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
// Wait for agent to appear
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Edit Me Agent')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click edit button (aria-label)
|
||||
const editButton = page.getByRole('button', { name: 'Edit agent' });
|
||||
await editButton.click();
|
||||
|
||||
// Edit the name in the dialog
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Edited Agent');
|
||||
|
||||
// Save changes
|
||||
await dialog.getByRole('button', { name: 'Save Changes' }).click();
|
||||
|
||||
// Should show updated name
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Edited Agent')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can delete agent', async ({ page }) => {
|
||||
// First create an agent
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Delete Me Agent');
|
||||
await page.getByLabel('Description').fill('Will be deleted');
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
// Wait for agent to appear
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Delete Me Agent')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click delete button (aria-label)
|
||||
const deleteButton = page.getByRole('button', { name: 'Delete agent' });
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirm deletion in dialog - look for the Delete button in the confirm dialog
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
const confirmDialog = page.getByRole('dialog');
|
||||
await confirmDialog.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Agent should be removed
|
||||
await expect(page.getByRole('heading', { name: 'Delete Me Agent' })).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can navigate to agents tab via navigation', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
|
||||
// Click on agents tab link
|
||||
const agentsTab = page.getByRole('link', { name: 'Agents' });
|
||||
await agentsTab.click();
|
||||
|
||||
// URL should update
|
||||
await expect(page).toHaveURL(/tab=agents/);
|
||||
|
||||
// Agents content should be visible
|
||||
await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Agent Tool Selection', () => {
|
||||
test('can select tools for agent', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Open create dialog
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Tool Agent');
|
||||
await page.getByLabel('Description').fill('Agent with specific tools');
|
||||
|
||||
// Look for Allowed Tools section
|
||||
await expect(page.getByText('Allowed Tools', { exact: true })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Save the agent
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
// Agent should be created
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Tool Agent')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Agent Prompt Selection', () => {
|
||||
test('can assign prompt to agent', async ({ page }) => {
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
// Open create dialog
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Prompt Agent');
|
||||
await page.getByLabel('Description').fill('Agent with a prompt');
|
||||
|
||||
// Look for System Prompt selector
|
||||
await expect(page.getByLabel('System Prompt')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Save the agent
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
// Agent should be created
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Prompt Agent')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Agent Chat Integration', () => {
|
||||
test('agent selector appears on home page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Agent selector button should be visible (shows "No agent" by default)
|
||||
await expect(page.getByRole('button', { name: /No agent/i })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('agent selector dropdown shows "No agents" when none exist', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Click on agent selector
|
||||
const agentButton = page.getByRole('button', { name: /No agent/i });
|
||||
await agentButton.click();
|
||||
|
||||
// Should show "No agents available" message
|
||||
await expect(page.getByText('No agents available')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should have link to create agents
|
||||
await expect(page.getByRole('link', { name: 'Create one' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('agent selector shows created agents', async ({ page }) => {
|
||||
// First create an agent
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Chat Agent');
|
||||
await page.getByLabel('Description').fill('Agent for chat testing');
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Now go to home page and check agent selector
|
||||
await page.goto('/');
|
||||
|
||||
const agentButton = page.getByRole('button', { name: /No agent/i });
|
||||
await agentButton.click();
|
||||
|
||||
// Should show the created agent
|
||||
await expect(page.getByText('Chat Agent')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Agent for chat testing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can select agent from dropdown', async ({ page }) => {
|
||||
// First create an agent
|
||||
await page.goto('/settings?tab=agents');
|
||||
|
||||
const createButton = page.getByRole('button', { name: 'Create Agent' }).first();
|
||||
await createButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByLabel('Name *').fill('Selectable Agent');
|
||||
await page.getByLabel('Description').fill('Can be selected');
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Create Agent' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Go to home page
|
||||
await page.goto('/');
|
||||
|
||||
// Open agent selector
|
||||
const agentButton = page.getByRole('button', { name: /No agent/i });
|
||||
await agentButton.click();
|
||||
|
||||
// Select the agent
|
||||
await page.getByText('Selectable Agent').click();
|
||||
|
||||
// Button should now show the agent name
|
||||
await expect(page.getByRole('button', { name: /Selectable Agent/i })).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -36,6 +36,7 @@
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/node": "^22.10.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.16.0",
|
||||
@@ -2557,6 +2558,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/node": "^22.10.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.16.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:7842',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:7842',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
217
frontend/src/lib/components/chat/AgentSelector.svelte
Normal file
217
frontend/src/lib/components/chat/AgentSelector.svelte
Normal file
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* AgentSelector - Dropdown to select an agent for the current conversation
|
||||
* Agents define a system prompt and tool set for the conversation
|
||||
*/
|
||||
import { agentsState, conversationsState, toastState } from '$lib/stores';
|
||||
import { updateAgentId } from '$lib/storage';
|
||||
|
||||
interface Props {
|
||||
conversationId?: string | null;
|
||||
currentAgentId?: string | null;
|
||||
/** Callback for 'new' mode - called when agent is selected without a conversation */
|
||||
onSelect?: (agentId: string | null) => void;
|
||||
}
|
||||
|
||||
let { conversationId = null, currentAgentId = null, onSelect }: Props = $props();
|
||||
|
||||
// UI state
|
||||
let isOpen = $state(false);
|
||||
let dropdownElement: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Available agents from store
|
||||
const agents = $derived(agentsState.sortedAgents);
|
||||
|
||||
// Current agent for this conversation
|
||||
const currentAgent = $derived(
|
||||
currentAgentId ? agents.find((a) => a.id === currentAgentId) : null
|
||||
);
|
||||
|
||||
// Display text for the button
|
||||
const buttonText = $derived(currentAgent?.name ?? 'No agent');
|
||||
|
||||
function toggleDropdown(): void {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
async function handleSelect(agentId: string | null): Promise<void> {
|
||||
// In 'new' mode (no conversation), use the callback
|
||||
if (!conversationId) {
|
||||
onSelect?.(agentId);
|
||||
const agentName = agentId ? agents.find((a) => a.id === agentId)?.name : null;
|
||||
toastState.success(agentName ? `Using "${agentName}"` : 'No agent selected');
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update in storage for existing conversation
|
||||
const result = await updateAgentId(conversationId, agentId);
|
||||
if (result.success) {
|
||||
conversationsState.setAgentId(conversationId, agentId);
|
||||
const agentName = agentId ? agents.find((a) => a.id === agentId)?.name : null;
|
||||
toastState.success(agentName ? `Using "${agentName}"` : 'No agent selected');
|
||||
} else {
|
||||
toastState.error('Failed to update agent');
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||
|
||||
<div class="relative" bind:this={dropdownElement}>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors {currentAgent
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-theme-muted hover:bg-theme-secondary hover:text-theme-secondary'}"
|
||||
title={currentAgent ? `Agent: ${currentAgent.name}` : 'Select an agent'}
|
||||
>
|
||||
<!-- Robot icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5">
|
||||
<path fill-rule="evenodd" d="M10 1a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 1ZM5.05 3.05a.75.75 0 0 1 1.06 0l1.062 1.06A.75.75 0 1 1 6.11 5.173L5.05 4.11a.75.75 0 0 1 0-1.06Zm9.9 0a.75.75 0 0 1 0 1.06l-1.06 1.062a.75.75 0 0 1-1.062-1.061l1.061-1.06a.75.75 0 0 1 1.06 0ZM3 8a7 7 0 0 1 14 0v2a1 1 0 0 0 1 1h.25a.75.75 0 0 1 0 1.5H18v1a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1h-.25a.75.75 0 0 1 0-1.5H2a1 1 0 0 0 1-1V8Zm5.75 3.5a.75.75 0 0 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Zm4 0a.75.75 0 0 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="max-w-[100px] truncate">{buttonText}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu (opens upward) -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="absolute bottom-full left-0 z-50 mb-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
|
||||
>
|
||||
<!-- No agent option -->
|
||||
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
|
||||
Default
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(null)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-theme-tertiary {!currentAgentId
|
||||
? 'bg-theme-tertiary/50 text-theme-primary'
|
||||
: 'text-theme-secondary'}"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span>No agent</span>
|
||||
<div class="mt-0.5 text-xs text-theme-muted">
|
||||
Use default tools and prompts
|
||||
</div>
|
||||
</div>
|
||||
{#if !currentAgentId}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 text-emerald-400"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if agents.length > 0}
|
||||
<div class="my-1 border-t border-theme"></div>
|
||||
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
|
||||
Your Agents
|
||||
</div>
|
||||
|
||||
<!-- Available agents -->
|
||||
{#each agents as agent}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(agent.id)}
|
||||
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-theme-tertiary {currentAgentId === agent.id
|
||||
? 'bg-theme-tertiary/50'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex-1 text-sm font-medium {currentAgentId === agent.id
|
||||
? 'text-theme-primary'
|
||||
: 'text-theme-secondary'}"
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
{#if currentAgentId === agent.id}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 text-emerald-400"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if agent.description}
|
||||
<span class="line-clamp-1 text-xs text-theme-muted">{agent.description}</span>
|
||||
{/if}
|
||||
{#if agent.enabledToolNames.length > 0}
|
||||
<span class="text-[10px] text-indigo-400">
|
||||
{agent.enabledToolNames.length} tool{agent.enabledToolNames.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="my-1 border-t border-theme"></div>
|
||||
<div class="px-3 py-2 text-xs text-theme-muted">
|
||||
No agents available. <a href="/settings?tab=agents" class="text-indigo-400 hover:underline"
|
||||
>Create one</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link to agents settings -->
|
||||
<div class="mt-1 border-t border-theme"></div>
|
||||
<a
|
||||
href="/settings?tab=agents"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
|
||||
onclick={closeDropdown}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5">
|
||||
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Manage agents
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles sending messages, streaming responses, and tool execution
|
||||
*/
|
||||
|
||||
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState } from '$lib/stores';
|
||||
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState, agentsState } from '$lib/stores';
|
||||
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
||||
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
|
||||
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
||||
@@ -34,6 +34,7 @@
|
||||
import SummaryBanner from './SummaryBanner.svelte';
|
||||
import StreamingStats from './StreamingStats.svelte';
|
||||
import SystemPromptSelector from './SystemPromptSelector.svelte';
|
||||
import AgentSelector from './AgentSelector.svelte';
|
||||
import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte';
|
||||
import { settingsState } from '$lib/stores/settings.svelte';
|
||||
import { buildProjectContext, formatProjectContextForPrompt, hasProjectContext } from '$lib/services/project-context.js';
|
||||
@@ -89,6 +90,9 @@
|
||||
// System prompt for new conversations (before a conversation is created)
|
||||
let newChatPromptId = $state<string | null>(null);
|
||||
|
||||
// Agent for new conversations (before a conversation is created)
|
||||
let newChatAgentId = $state<string | null>(null);
|
||||
|
||||
// File picker trigger function (bound from ChatInput -> FileUpload)
|
||||
let triggerFilePicker: (() => void) | undefined = $state();
|
||||
|
||||
@@ -229,9 +233,18 @@
|
||||
|
||||
/**
|
||||
* Get tool definitions for the API call
|
||||
* If an agent is selected, only returns tools the agent has enabled
|
||||
*/
|
||||
function getToolsForApi(): OllamaToolDefinition[] | undefined {
|
||||
if (!toolsState.toolsEnabled) return undefined;
|
||||
|
||||
// If an agent is selected, filter tools by agent's enabled list
|
||||
if (currentAgent) {
|
||||
const tools = toolsState.getToolDefinitionsForAgent(currentAgent.enabledToolNames);
|
||||
return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined;
|
||||
}
|
||||
|
||||
// No agent - use all enabled tools
|
||||
const tools = toolsState.getEnabledToolDefinitions();
|
||||
return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined;
|
||||
}
|
||||
@@ -239,6 +252,13 @@
|
||||
// Derived: Check if there are any messages
|
||||
const hasMessages = $derived(chatState.visibleMessages.length > 0);
|
||||
|
||||
// Derived: Current agent (from conversation or new chat selection)
|
||||
const currentAgent = $derived.by(() => {
|
||||
const agentId = mode === 'conversation' ? conversation?.agentId : newChatAgentId;
|
||||
if (!agentId) return null;
|
||||
return agentsState.get(agentId) ?? null;
|
||||
});
|
||||
|
||||
// Update context manager when model changes
|
||||
$effect(() => {
|
||||
const model = modelsState.selectedId;
|
||||
@@ -725,15 +745,18 @@
|
||||
// Resolve system prompt using priority chain:
|
||||
// 1. Per-conversation prompt
|
||||
// 2. New chat selection
|
||||
// 3. Model-prompt mapping
|
||||
// 4. Model-embedded prompt (from Modelfile)
|
||||
// 5. Capability-matched prompt
|
||||
// 6. Global active prompt
|
||||
// 7. None
|
||||
// 3. Agent prompt (if agent selected)
|
||||
// 4. Model-prompt mapping
|
||||
// 5. Model-embedded prompt (from Modelfile)
|
||||
// 6. Capability-matched prompt
|
||||
// 7. Global active prompt
|
||||
// 8. None
|
||||
const resolvedPrompt = await resolveSystemPrompt(
|
||||
model,
|
||||
conversation?.systemPromptId,
|
||||
newChatPromptId
|
||||
newChatPromptId,
|
||||
currentAgent?.promptId,
|
||||
currentAgent?.name
|
||||
);
|
||||
|
||||
if (resolvedPrompt.content) {
|
||||
@@ -1281,6 +1304,19 @@
|
||||
onSelect={(promptId) => (newChatPromptId = promptId)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Agent selector -->
|
||||
{#if mode === 'conversation' && conversation}
|
||||
<AgentSelector
|
||||
conversationId={conversation.id}
|
||||
currentAgentId={conversation.agentId}
|
||||
/>
|
||||
{:else if mode === 'new'}
|
||||
<AgentSelector
|
||||
currentAgentId={newChatAgentId}
|
||||
onSelect={(agentId) => (newChatAgentId = agentId)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right side: Attach files + Thinking mode toggle -->
|
||||
|
||||
@@ -211,10 +211,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<!-- Dropdown menu (opens upward) -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="absolute left-0 top-full z-50 mt-1 w-72 rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
|
||||
class="absolute bottom-full left-0 z-50 mb-1 max-h-80 w-72 overflow-y-auto rounded-lg border border-theme bg-theme-secondary py-1 shadow-xl"
|
||||
>
|
||||
<!-- Model default section -->
|
||||
<div class="px-3 py-1.5 text-xs font-medium text-theme-muted uppercase tracking-wide">
|
||||
|
||||
500
frontend/src/lib/components/settings/AgentsTab.svelte
Normal file
500
frontend/src/lib/components/settings/AgentsTab.svelte
Normal file
@@ -0,0 +1,500 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* AgentsTab - Agent management settings tab
|
||||
* CRUD operations for agents with prompt and tool configuration
|
||||
*/
|
||||
import { agentsState, promptsState, toolsState } from '$lib/stores';
|
||||
import type { Agent } from '$lib/storage';
|
||||
import { ConfirmDialog } from '$lib/components/shared';
|
||||
|
||||
let showEditor = $state(false);
|
||||
let editingAgent = $state<Agent | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let deleteConfirm = $state<{ show: boolean; agent: Agent | null }>({ show: false, agent: null });
|
||||
|
||||
// Form state
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formPromptId = $state<string | null>(null);
|
||||
let formPreferredModel = $state<string | null>(null);
|
||||
let formEnabledTools = $state<Set<string>>(new Set());
|
||||
|
||||
// Stats
|
||||
const stats = $derived({
|
||||
total: agentsState.agents.length
|
||||
});
|
||||
|
||||
// Filtered agents based on search
|
||||
const filteredAgents = $derived(
|
||||
searchQuery.trim()
|
||||
? agentsState.sortedAgents.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: agentsState.sortedAgents
|
||||
);
|
||||
|
||||
// Available tools for selection
|
||||
const availableTools = $derived(
|
||||
toolsState.getAllToolsWithState().map((t) => ({
|
||||
name: t.definition.function.name,
|
||||
description: t.definition.function.description,
|
||||
isBuiltin: t.isBuiltin
|
||||
}))
|
||||
);
|
||||
|
||||
function openCreateEditor(): void {
|
||||
editingAgent = null;
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
formPromptId = null;
|
||||
formPreferredModel = null;
|
||||
formEnabledTools = new Set();
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function openEditEditor(agent: Agent): void {
|
||||
editingAgent = agent;
|
||||
formName = agent.name;
|
||||
formDescription = agent.description;
|
||||
formPromptId = agent.promptId;
|
||||
formPreferredModel = agent.preferredModel;
|
||||
formEnabledTools = new Set(agent.enabledToolNames);
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
showEditor = false;
|
||||
editingAgent = null;
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!formName.trim()) return;
|
||||
|
||||
const data = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim(),
|
||||
promptId: formPromptId,
|
||||
preferredModel: formPreferredModel,
|
||||
enabledToolNames: Array.from(formEnabledTools)
|
||||
};
|
||||
|
||||
if (editingAgent) {
|
||||
await agentsState.update(editingAgent.id, data);
|
||||
} else {
|
||||
await agentsState.add(data);
|
||||
}
|
||||
|
||||
closeEditor();
|
||||
}
|
||||
|
||||
function handleDelete(agent: Agent): void {
|
||||
deleteConfirm = { show: true, agent };
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (deleteConfirm.agent) {
|
||||
await agentsState.remove(deleteConfirm.agent.id);
|
||||
}
|
||||
deleteConfirm = { show: false, agent: null };
|
||||
}
|
||||
|
||||
function toggleTool(toolName: string): void {
|
||||
const newSet = new Set(formEnabledTools);
|
||||
if (newSet.has(toolName)) {
|
||||
newSet.delete(toolName);
|
||||
} else {
|
||||
newSet.add(toolName);
|
||||
}
|
||||
formEnabledTools = newSet;
|
||||
}
|
||||
|
||||
function getPromptName(promptId: string | null): string {
|
||||
if (!promptId) return 'No prompt';
|
||||
const prompt = promptsState.get(promptId);
|
||||
return prompt?.name ?? 'Unknown prompt';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-theme-primary">Agents</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create specialized agents with custom prompts and tool sets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<p class="text-sm text-theme-muted">Total Agents</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-theme-primary">{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if agentsState.agents.length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search agents..."
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary py-2 pl-10 pr-4 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (searchQuery = '')}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Agents List -->
|
||||
{#if filteredAgents.length === 0 && agentsState.agents.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-theme-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||
/>
|
||||
</svg>
|
||||
<h4 class="mt-4 text-sm font-medium text-theme-secondary">No agents yet</h4>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create agents to combine prompts and tools for specialized tasks
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 transition-colors hover:bg-violet-900/30"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Your First Agent
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredAgents.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<p class="text-sm text-theme-muted">No agents match your search</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredAgents as agent (agent.id)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Agent Icon -->
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-900/30 text-violet-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-theme-primary">{agent.name}</h4>
|
||||
{#if agent.promptId}
|
||||
<span class="rounded-full bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-300">
|
||||
{getPromptName(agent.promptId)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if agent.enabledToolNames.length > 0}
|
||||
<span
|
||||
class="rounded-full bg-emerald-900/40 px-2 py-0.5 text-xs font-medium text-emerald-300"
|
||||
>
|
||||
{agent.enabledToolNames.length} tools
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if agent.description}
|
||||
<p class="mt-1 text-sm text-theme-muted line-clamp-2">
|
||||
{agent.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditEditor(agent)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
aria-label="Edit agent"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(agent)}
|
||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
aria-label="Delete agent"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info Section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-gradient-to-br from-theme-secondary/80 to-theme-secondary/40 p-5">
|
||||
<h4 class="flex items-center gap-2 text-sm font-semibold text-theme-primary">
|
||||
<svg class="h-5 w-5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
About Agents
|
||||
</h4>
|
||||
<p class="mt-3 text-sm leading-relaxed text-theme-muted">
|
||||
Agents combine a system prompt with a specific set of tools. When you select an agent for a
|
||||
chat, it will use the agent's prompt and only have access to the agent's allowed tools.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-blue-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
System Prompt
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Defines the agent's personality and behavior</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-theme-tertiary/50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-emerald-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
Tool Access
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">Restricts which tools the agent can use</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Editor Dialog -->
|
||||
{#if showEditor}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="agent-editor-title"
|
||||
>
|
||||
<div class="w-full max-w-2xl rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h3 id="agent-editor-title" class="text-lg font-semibold text-theme-primary">
|
||||
{editingAgent ? 'Edit Agent' : 'Create Agent'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeEditor}
|
||||
class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
class="max-h-[70vh] overflow-y-auto p-6"
|
||||
>
|
||||
<!-- Name -->
|
||||
<div class="mb-4">
|
||||
<label for="agent-name" class="mb-1 block text-sm font-medium text-theme-primary">
|
||||
Name <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="agent-name"
|
||||
type="text"
|
||||
bind:value={formName}
|
||||
placeholder="e.g., Research Assistant"
|
||||
required
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="agent-description" class="mb-1 block text-sm font-medium text-theme-primary">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="agent-description"
|
||||
bind:value={formDescription}
|
||||
placeholder="Describe what this agent does..."
|
||||
rows={3}
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary placeholder:text-theme-muted focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="agent-prompt" class="mb-1 block text-sm font-medium text-theme-primary">
|
||||
System Prompt
|
||||
</label>
|
||||
<select
|
||||
id="agent-prompt"
|
||||
bind:value={formPromptId}
|
||||
class="w-full rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-primary focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value={null}>No specific prompt (use defaults)</option>
|
||||
{#each promptsState.prompts as prompt (prompt.id)}
|
||||
<option value={prompt.id}>{prompt.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Select a prompt from your library to use with this agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tools Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-theme-primary"> Allowed Tools </label>
|
||||
<div class="max-h-48 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-2">
|
||||
{#if availableTools.length === 0}
|
||||
<p class="p-2 text-sm text-theme-muted">No tools available</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each availableTools as tool (tool.name)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded p-2 hover:bg-theme-tertiary"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabledTools.has(tool.name)}
|
||||
onchange={() => toggleTool(tool.name)}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-theme-tertiary text-violet-500 focus:ring-violet-500"
|
||||
/>
|
||||
<span class="text-sm text-theme-primary">{tool.name}</span>
|
||||
{#if tool.isBuiltin}
|
||||
<span class="text-xs text-blue-400">(built-in)</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
{formEnabledTools.size === 0
|
||||
? 'All tools will be available (no restrictions)'
|
||||
: `${formEnabledTools.size} tool(s) selected`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeEditor}
|
||||
class="rounded-lg border border-theme px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!formName.trim()}
|
||||
class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{editingAgent ? 'Save Changes' : 'Create Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
title="Delete Agent"
|
||||
message={`Delete "${deleteConfirm.agent?.name}"? This cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (deleteConfirm = { show: false, agent: null })}
|
||||
/>
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* SettingsTabs - Horizontal tab navigation for Settings Hub
|
||||
*/
|
||||
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory';
|
||||
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'agents' | 'knowledge' | 'memory';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -19,6 +19,7 @@
|
||||
{ id: 'models', label: 'Models', icon: 'cpu' },
|
||||
{ id: 'prompts', label: 'Prompts', icon: 'message' },
|
||||
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
||||
{ id: 'agents', label: 'Agents', icon: 'robot' },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
|
||||
{ id: 'memory', label: 'Memory', icon: 'brain' }
|
||||
];
|
||||
@@ -59,6 +60,10 @@
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'robot'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
{:else if tab.icon === 'brain'}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
|
||||
@@ -6,6 +6,7 @@ export { default as GeneralTab } from './GeneralTab.svelte';
|
||||
export { default as ModelsTab } from './ModelsTab.svelte';
|
||||
export { default as PromptsTab } from './PromptsTab.svelte';
|
||||
export { default as ToolsTab } from './ToolsTab.svelte';
|
||||
export { default as AgentsTab } from './AgentsTab.svelte';
|
||||
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
|
||||
export { default as MemoryTab } from './MemoryTab.svelte';
|
||||
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('getPromptSourceLabel', () => {
|
||||
const testCases: Array<{ source: PromptSource; expected: string }> = [
|
||||
{ source: 'per-conversation', expected: 'Custom (this chat)' },
|
||||
{ source: 'new-chat-selection', expected: 'Selected prompt' },
|
||||
{ source: 'agent', expected: 'Agent prompt' },
|
||||
{ source: 'model-mapping', expected: 'Model default' },
|
||||
{ source: 'model-embedded', expected: 'Model built-in' },
|
||||
{ source: 'capability-match', expected: 'Auto-matched' },
|
||||
@@ -29,6 +30,7 @@ describe('getPromptSourceLabel', () => {
|
||||
const allSources: PromptSource[] = [
|
||||
'per-conversation',
|
||||
'new-chat-selection',
|
||||
'agent',
|
||||
'model-mapping',
|
||||
'model-embedded',
|
||||
'capability-match',
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { OllamaCapability } from '$lib/ollama/types.js';
|
||||
export type PromptSource =
|
||||
| 'per-conversation'
|
||||
| 'new-chat-selection'
|
||||
| 'agent'
|
||||
| 'model-mapping'
|
||||
| 'model-embedded'
|
||||
| 'capability-match'
|
||||
@@ -72,21 +73,26 @@ function findCapabilityMatchedPrompt(
|
||||
* Priority order:
|
||||
* 1. Per-conversation prompt (explicit user override)
|
||||
* 2. New chat prompt selection (before conversation exists)
|
||||
* 3. Model-prompt mapping (user configured default for model)
|
||||
* 4. Model-embedded prompt (from Ollama Modelfile)
|
||||
* 5. Capability-matched prompt
|
||||
* 6. Global active prompt
|
||||
* 7. No prompt
|
||||
* 3. Agent prompt (if agent is specified and has a promptId)
|
||||
* 4. Model-prompt mapping (user configured default for model)
|
||||
* 5. Model-embedded prompt (from Ollama Modelfile)
|
||||
* 6. Capability-matched prompt
|
||||
* 7. Global active prompt
|
||||
* 8. No prompt
|
||||
*
|
||||
* @param modelName - Ollama model name (e.g., "llama3.2:8b")
|
||||
* @param conversationPromptId - Per-conversation prompt ID (if set)
|
||||
* @param newChatPromptId - New chat selection (before conversation created)
|
||||
* @param agentPromptId - Agent's prompt ID (if agent is selected)
|
||||
* @param agentName - Agent's name for display (optional)
|
||||
* @returns Resolved prompt with content and source
|
||||
*/
|
||||
export async function resolveSystemPrompt(
|
||||
modelName: string,
|
||||
conversationPromptId?: string | null,
|
||||
newChatPromptId?: string | null
|
||||
newChatPromptId?: string | null,
|
||||
agentPromptId?: string | null,
|
||||
agentName?: string
|
||||
): Promise<ResolvedPrompt> {
|
||||
// Ensure stores are loaded
|
||||
await promptsState.ready();
|
||||
@@ -116,7 +122,19 @@ export async function resolveSystemPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User-configured model-prompt mapping
|
||||
// 3. Agent prompt (if agent is specified and has a promptId)
|
||||
if (agentPromptId) {
|
||||
const prompt = promptsState.get(agentPromptId);
|
||||
if (prompt) {
|
||||
return {
|
||||
content: prompt.content,
|
||||
source: 'agent',
|
||||
promptName: agentName ? `${agentName}: ${prompt.name}` : prompt.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. User-configured model-prompt mapping
|
||||
const mappedPromptId = modelPromptMappingsState.getMapping(modelName);
|
||||
if (mappedPromptId) {
|
||||
const prompt = promptsState.get(mappedPromptId);
|
||||
@@ -129,7 +147,7 @@ export async function resolveSystemPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
|
||||
// 5. Model-embedded prompt (from Ollama Modelfile SYSTEM directive)
|
||||
const modelInfo = await modelInfoService.getModelInfo(modelName);
|
||||
if (modelInfo.systemPrompt) {
|
||||
return {
|
||||
@@ -139,7 +157,7 @@ export async function resolveSystemPrompt(
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Capability-matched prompt
|
||||
// 6. Capability-matched prompt
|
||||
if (modelInfo.capabilities.length > 0) {
|
||||
const capabilityMatch = findCapabilityMatchedPrompt(modelInfo.capabilities, promptsState.prompts);
|
||||
if (capabilityMatch) {
|
||||
@@ -152,7 +170,7 @@ export async function resolveSystemPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Global active prompt
|
||||
// 7. Global active prompt
|
||||
const activePrompt = promptsState.activePrompt;
|
||||
if (activePrompt) {
|
||||
return {
|
||||
@@ -162,7 +180,7 @@ export async function resolveSystemPrompt(
|
||||
};
|
||||
}
|
||||
|
||||
// 7. No prompt
|
||||
// 8. No prompt
|
||||
return {
|
||||
content: '',
|
||||
source: 'none'
|
||||
@@ -181,6 +199,8 @@ export function getPromptSourceLabel(source: PromptSource): string {
|
||||
return 'Custom (this chat)';
|
||||
case 'new-chat-selection':
|
||||
return 'Selected prompt';
|
||||
case 'agent':
|
||||
return 'Agent prompt';
|
||||
case 'model-mapping':
|
||||
return 'Model default';
|
||||
case 'model-embedded':
|
||||
|
||||
366
frontend/src/lib/storage/agents.test.ts
Normal file
366
frontend/src/lib/storage/agents.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Agents storage layer tests
|
||||
*
|
||||
* Tests CRUD operations for agents and project-agent relationships.
|
||||
* Uses fake-indexeddb for in-memory IndexedDB testing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { db, generateId } from './db.js';
|
||||
import {
|
||||
createAgent,
|
||||
getAllAgents,
|
||||
getAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
assignAgentToProject,
|
||||
removeAgentFromProject,
|
||||
getAgentsForProject
|
||||
} from './agents.js';
|
||||
|
||||
describe('agents storage', () => {
|
||||
// Reset database before each test
|
||||
beforeEach(async () => {
|
||||
// Clear all agent-related tables
|
||||
await db.agents.clear();
|
||||
await db.projectAgents.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.agents.clear();
|
||||
await db.projectAgents.clear();
|
||||
});
|
||||
|
||||
describe('createAgent', () => {
|
||||
it('creates agent with required fields', async () => {
|
||||
const result = await createAgent({
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Test Agent');
|
||||
expect(result.data.description).toBe('A test agent');
|
||||
}
|
||||
});
|
||||
|
||||
it('generates unique id', async () => {
|
||||
const result1 = await createAgent({ name: 'Agent 1', description: '' });
|
||||
const result2 = await createAgent({ name: 'Agent 2', description: '' });
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
if (result1.success && result2.success) {
|
||||
expect(result1.data.id).not.toBe(result2.data.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets createdAt and updatedAt timestamps', async () => {
|
||||
const before = Date.now();
|
||||
const result = await createAgent({ name: 'Agent', description: '' });
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.createdAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(result.data.createdAt.getTime()).toBeLessThanOrEqual(after);
|
||||
expect(result.data.updatedAt.getTime()).toBe(result.data.createdAt.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('stores optional promptId', async () => {
|
||||
const promptId = generateId();
|
||||
const result = await createAgent({
|
||||
name: 'Agent with Prompt',
|
||||
description: '',
|
||||
promptId
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.promptId).toBe(promptId);
|
||||
}
|
||||
});
|
||||
|
||||
it('stores enabledToolNames array', async () => {
|
||||
const tools = ['fetch_url', 'web_search', 'calculate'];
|
||||
const result = await createAgent({
|
||||
name: 'Agent with Tools',
|
||||
description: '',
|
||||
enabledToolNames: tools
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.enabledToolNames).toEqual(tools);
|
||||
}
|
||||
});
|
||||
|
||||
it('stores optional preferredModel', async () => {
|
||||
const result = await createAgent({
|
||||
name: 'Agent with Model',
|
||||
description: '',
|
||||
preferredModel: 'llama3.2:8b'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.preferredModel).toBe('llama3.2:8b');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults optional fields appropriately', async () => {
|
||||
const result = await createAgent({
|
||||
name: 'Minimal Agent',
|
||||
description: 'Just the basics'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.promptId).toBeNull();
|
||||
expect(result.data.enabledToolNames).toEqual([]);
|
||||
expect(result.data.preferredModel).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllAgents', () => {
|
||||
it('returns empty array when no agents', async () => {
|
||||
const result = await getAllAgents();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns all agents sorted by name', async () => {
|
||||
await createAgent({ name: 'Charlie', description: '' });
|
||||
await createAgent({ name: 'Alice', description: '' });
|
||||
await createAgent({ name: 'Bob', description: '' });
|
||||
|
||||
const result = await getAllAgents();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.length).toBe(3);
|
||||
expect(result.data[0].name).toBe('Alice');
|
||||
expect(result.data[1].name).toBe('Bob');
|
||||
expect(result.data[2].name).toBe('Charlie');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgent', () => {
|
||||
it('returns agent by id', async () => {
|
||||
const createResult = await createAgent({ name: 'Test Agent', description: 'desc' });
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const result = await getAgent(createResult.data.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data?.name).toBe('Test Agent');
|
||||
expect(result.data?.description).toBe('desc');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for non-existent id', async () => {
|
||||
const result = await getAgent('non-existent-id');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAgent', () => {
|
||||
it('updates name', async () => {
|
||||
const createResult = await createAgent({ name: 'Original', description: '' });
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const result = await updateAgent(createResult.data.id, { name: 'Updated' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Updated');
|
||||
}
|
||||
});
|
||||
|
||||
it('updates enabledToolNames', async () => {
|
||||
const createResult = await createAgent({
|
||||
name: 'Agent',
|
||||
description: '',
|
||||
enabledToolNames: ['tool1']
|
||||
});
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const result = await updateAgent(createResult.data.id, {
|
||||
enabledToolNames: ['tool1', 'tool2', 'tool3']
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.enabledToolNames).toEqual(['tool1', 'tool2', 'tool3']);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates updatedAt timestamp', async () => {
|
||||
const createResult = await createAgent({ name: 'Agent', description: '' });
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const originalUpdatedAt = createResult.data.updatedAt.getTime();
|
||||
|
||||
// Small delay to ensure timestamp differs
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const result = await updateAgent(createResult.data.id, { description: 'new description' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns error for non-existent agent', async () => {
|
||||
const result = await updateAgent('non-existent-id', { name: 'Updated' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('not found');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAgent', () => {
|
||||
it('removes agent from database', async () => {
|
||||
const createResult = await createAgent({ name: 'To Delete', description: '' });
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const deleteResult = await deleteAgent(createResult.data.id);
|
||||
expect(deleteResult.success).toBe(true);
|
||||
|
||||
const getResult = await getAgent(createResult.data.id);
|
||||
expect(getResult.success).toBe(true);
|
||||
if (getResult.success) {
|
||||
expect(getResult.data).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes project-agent associations', async () => {
|
||||
const createResult = await createAgent({ name: 'Agent', description: '' });
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) return;
|
||||
|
||||
const projectId = generateId();
|
||||
await assignAgentToProject(createResult.data.id, projectId);
|
||||
|
||||
// Verify assignment exists
|
||||
let agents = await getAgentsForProject(projectId);
|
||||
expect(agents.success).toBe(true);
|
||||
if (agents.success) {
|
||||
expect(agents.data.length).toBe(1);
|
||||
}
|
||||
|
||||
// Delete agent
|
||||
await deleteAgent(createResult.data.id);
|
||||
|
||||
// Verify association removed
|
||||
agents = await getAgentsForProject(projectId);
|
||||
expect(agents.success).toBe(true);
|
||||
if (agents.success) {
|
||||
expect(agents.data.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('project-agent relationships', () => {
|
||||
it('assigns agent to project', async () => {
|
||||
const agentResult = await createAgent({ name: 'Agent', description: '' });
|
||||
expect(agentResult.success).toBe(true);
|
||||
if (!agentResult.success) return;
|
||||
|
||||
const projectId = generateId();
|
||||
const assignResult = await assignAgentToProject(agentResult.data.id, projectId);
|
||||
|
||||
expect(assignResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('removes agent from project', async () => {
|
||||
const agentResult = await createAgent({ name: 'Agent', description: '' });
|
||||
expect(agentResult.success).toBe(true);
|
||||
if (!agentResult.success) return;
|
||||
|
||||
const projectId = generateId();
|
||||
await assignAgentToProject(agentResult.data.id, projectId);
|
||||
|
||||
const removeResult = await removeAgentFromProject(agentResult.data.id, projectId);
|
||||
expect(removeResult.success).toBe(true);
|
||||
|
||||
const agents = await getAgentsForProject(projectId);
|
||||
expect(agents.success).toBe(true);
|
||||
if (agents.success) {
|
||||
expect(agents.data.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('gets agents for project', async () => {
|
||||
const agent1 = await createAgent({ name: 'Agent 1', description: '' });
|
||||
const agent2 = await createAgent({ name: 'Agent 2', description: '' });
|
||||
const agent3 = await createAgent({ name: 'Agent 3', description: '' });
|
||||
expect(agent1.success && agent2.success && agent3.success).toBe(true);
|
||||
if (!agent1.success || !agent2.success || !agent3.success) return;
|
||||
|
||||
const projectId = generateId();
|
||||
const otherProjectId = generateId();
|
||||
|
||||
await assignAgentToProject(agent1.data.id, projectId);
|
||||
await assignAgentToProject(agent2.data.id, projectId);
|
||||
await assignAgentToProject(agent3.data.id, otherProjectId);
|
||||
|
||||
const result = await getAgentsForProject(projectId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.length).toBe(2);
|
||||
const names = result.data.map((a) => a.name).sort();
|
||||
expect(names).toEqual(['Agent 1', 'Agent 2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('prevents duplicate assignments', async () => {
|
||||
const agentResult = await createAgent({ name: 'Agent', description: '' });
|
||||
expect(agentResult.success).toBe(true);
|
||||
if (!agentResult.success) return;
|
||||
|
||||
const projectId = generateId();
|
||||
await assignAgentToProject(agentResult.data.id, projectId);
|
||||
await assignAgentToProject(agentResult.data.id, projectId); // Duplicate
|
||||
|
||||
const agents = await getAgentsForProject(projectId);
|
||||
expect(agents.success).toBe(true);
|
||||
if (agents.success) {
|
||||
// Should still be only one assignment
|
||||
expect(agents.data.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty array for project with no agents', async () => {
|
||||
const projectId = generateId();
|
||||
const result = await getAgentsForProject(projectId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
220
frontend/src/lib/storage/agents.ts
Normal file
220
frontend/src/lib/storage/agents.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Agents storage operations
|
||||
* CRUD operations for agents and project-agent relationships
|
||||
*/
|
||||
|
||||
import { db, generateId, withErrorHandling } from './db.js';
|
||||
import type { StoredAgent, StoredProjectAgent, StorageResult } from './db.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agent for UI display (with Date objects)
|
||||
*/
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
promptId: string | null;
|
||||
enabledToolNames: string[];
|
||||
preferredModel: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateAgentData {
|
||||
name: string;
|
||||
description: string;
|
||||
promptId?: string | null;
|
||||
enabledToolNames?: string[];
|
||||
preferredModel?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
promptId?: string | null;
|
||||
enabledToolNames?: string[];
|
||||
preferredModel?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Converters
|
||||
// ============================================================================
|
||||
|
||||
function toDomainAgent(stored: StoredAgent): Agent {
|
||||
return {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
description: stored.description,
|
||||
promptId: stored.promptId,
|
||||
enabledToolNames: stored.enabledToolNames,
|
||||
preferredModel: stored.preferredModel,
|
||||
createdAt: new Date(stored.createdAt),
|
||||
updatedAt: new Date(stored.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent CRUD
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all agents, sorted by name
|
||||
*/
|
||||
export async function getAllAgents(): Promise<StorageResult<Agent[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const all = await db.agents.toArray();
|
||||
const sorted = all.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return sorted.map(toDomainAgent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single agent by ID
|
||||
*/
|
||||
export async function getAgent(id: string): Promise<StorageResult<Agent | null>> {
|
||||
return withErrorHandling(async () => {
|
||||
const stored = await db.agents.get(id);
|
||||
return stored ? toDomainAgent(stored) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent
|
||||
*/
|
||||
export async function createAgent(data: CreateAgentData): Promise<StorageResult<Agent>> {
|
||||
return withErrorHandling(async () => {
|
||||
const now = Date.now();
|
||||
const stored: StoredAgent = {
|
||||
id: generateId(),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
promptId: data.promptId ?? null,
|
||||
enabledToolNames: data.enabledToolNames ?? [],
|
||||
preferredModel: data.preferredModel ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
await db.agents.add(stored);
|
||||
return toDomainAgent(stored);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing agent
|
||||
*/
|
||||
export async function updateAgent(
|
||||
id: string,
|
||||
updates: UpdateAgentData
|
||||
): Promise<StorageResult<Agent>> {
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await db.agents.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Agent not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: StoredAgent = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
await db.agents.put(updated);
|
||||
return toDomainAgent(updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent and all associated project assignments
|
||||
*/
|
||||
export async function deleteAgent(id: string): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.transaction('rw', [db.agents, db.projectAgents], async () => {
|
||||
// Remove all project-agent associations
|
||||
await db.projectAgents.where('agentId').equals(id).delete();
|
||||
// Delete the agent itself
|
||||
await db.agents.delete(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project-Agent Relationships
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assign an agent to a project (adds to roster)
|
||||
* Idempotent: does nothing if assignment already exists
|
||||
*/
|
||||
export async function assignAgentToProject(
|
||||
agentId: string,
|
||||
projectId: string
|
||||
): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
// Check if assignment already exists
|
||||
const existing = await db.projectAgents
|
||||
.where('[projectId+agentId]')
|
||||
.equals([projectId, agentId])
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
return; // Already assigned, do nothing
|
||||
}
|
||||
|
||||
const assignment: StoredProjectAgent = {
|
||||
id: generateId(),
|
||||
projectId,
|
||||
agentId,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
await db.projectAgents.add(assignment);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from a project roster
|
||||
*/
|
||||
export async function removeAgentFromProject(
|
||||
agentId: string,
|
||||
projectId: string
|
||||
): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.projectAgents
|
||||
.where('[projectId+agentId]')
|
||||
.equals([projectId, agentId])
|
||||
.delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agents assigned to a project (sorted by name)
|
||||
*/
|
||||
export async function getAgentsForProject(projectId: string): Promise<StorageResult<Agent[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const assignments = await db.projectAgents.where('projectId').equals(projectId).toArray();
|
||||
const agentIds = assignments.map((a) => a.agentId);
|
||||
|
||||
if (agentIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const agents = await db.agents.where('id').anyOf(agentIds).toArray();
|
||||
const sorted = agents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return sorted.map(toDomainAgent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all project IDs that an agent is assigned to
|
||||
*/
|
||||
export async function getProjectsForAgent(agentId: string): Promise<StorageResult<string[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const assignments = await db.projectAgents.where('agentId').equals(agentId).toArray();
|
||||
return assignments.map((a) => a.projectId);
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function toDomainConversation(stored: StoredConversation): Conversation {
|
||||
messageCount: stored.messageCount,
|
||||
systemPromptId: stored.systemPromptId ?? null,
|
||||
projectId: stored.projectId ?? null,
|
||||
agentId: stored.agentId ?? null,
|
||||
summary: stored.summary ?? null,
|
||||
summaryUpdatedAt: stored.summaryUpdatedAt ? new Date(stored.summaryUpdatedAt) : null
|
||||
};
|
||||
@@ -296,6 +297,16 @@ export async function updateSystemPrompt(
|
||||
return updateConversation(conversationId, { systemPromptId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the agent for a conversation
|
||||
*/
|
||||
export async function updateAgentId(
|
||||
conversationId: string,
|
||||
agentId: string | null
|
||||
): Promise<StorageResult<Conversation>> {
|
||||
return updateConversation(conversationId, { agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search conversations by title
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface StoredConversation {
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Optional agent ID for this conversation (determines prompt and tools) */
|
||||
agentId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
@@ -46,6 +48,8 @@ export interface ConversationRecord {
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Optional agent ID for this conversation (determines prompt and tools) */
|
||||
agentId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
@@ -266,6 +270,39 @@ export interface StoredChatChunk {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent-related interfaces (v7)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Stored agent configuration
|
||||
* Agents combine identity, system prompt, and tool subset
|
||||
*/
|
||||
export interface StoredAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Reference to StoredPrompt.id, null for no specific prompt */
|
||||
promptId: string | null;
|
||||
/** Array of tool names this agent can use (subset of available tools) */
|
||||
enabledToolNames: string[];
|
||||
/** Optional preferred model for this agent */
|
||||
preferredModel: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Junction table for project-agent many-to-many relationship
|
||||
* Defines which agents are available (rostered) for a project
|
||||
*/
|
||||
export interface StoredProjectAgent {
|
||||
id: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama WebUI database class
|
||||
* Manages all local storage tables
|
||||
@@ -284,6 +321,9 @@ class OllamaDatabase extends Dexie {
|
||||
projects!: Table<StoredProject>;
|
||||
projectLinks!: Table<StoredProjectLink>;
|
||||
chatChunks!: Table<StoredChatChunk>;
|
||||
// Agent-related tables (v7)
|
||||
agents!: Table<StoredAgent>;
|
||||
projectAgents!: Table<StoredProjectAgent>;
|
||||
|
||||
constructor() {
|
||||
super('vessel');
|
||||
@@ -374,6 +414,27 @@ class OllamaDatabase extends Dexie {
|
||||
// Chat message chunks for cross-conversation RAG within projects
|
||||
chatChunks: 'id, conversationId, projectId, createdAt'
|
||||
});
|
||||
|
||||
// Version 7: Agents for specialized task handling
|
||||
// Adds: agents table and project-agent junction table for roster assignment
|
||||
this.version(7).stores({
|
||||
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId, projectId',
|
||||
messages: 'id, conversationId, parentId, createdAt',
|
||||
attachments: 'id, messageId',
|
||||
syncQueue: 'id, entityType, createdAt',
|
||||
documents: 'id, name, createdAt, updatedAt, projectId',
|
||||
chunks: 'id, documentId',
|
||||
prompts: 'id, name, isDefault, updatedAt',
|
||||
modelSystemPrompts: 'modelName',
|
||||
modelPromptMappings: 'id, modelName, promptId',
|
||||
projects: 'id, name, createdAt, updatedAt',
|
||||
projectLinks: 'id, projectId, createdAt',
|
||||
chatChunks: 'id, conversationId, projectId, createdAt',
|
||||
// Agents: indexed by id and name for lookup/sorting
|
||||
agents: 'id, name, createdAt, updatedAt',
|
||||
// Project-Agent junction table with compound index for efficient queries
|
||||
projectAgents: 'id, projectId, agentId, [projectId+agentId]'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export type {
|
||||
StoredAttachment,
|
||||
SyncQueueItem,
|
||||
StoredPrompt,
|
||||
StoredAgent,
|
||||
StoredProjectAgent,
|
||||
StorageResult
|
||||
} from './db.js';
|
||||
|
||||
@@ -27,6 +29,7 @@ export {
|
||||
archiveConversation,
|
||||
updateMessageCount,
|
||||
updateSystemPrompt,
|
||||
updateAgentId,
|
||||
searchConversations
|
||||
} from './conversations.js';
|
||||
|
||||
@@ -103,3 +106,17 @@ export {
|
||||
clearDefaultPrompt,
|
||||
searchPrompts
|
||||
} from './prompts.js';
|
||||
|
||||
// Agent operations
|
||||
export {
|
||||
getAllAgents,
|
||||
getAgent,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
assignAgentToProject,
|
||||
removeAgentFromProject,
|
||||
getAgentsForProject,
|
||||
getProjectsForAgent
|
||||
} from './agents.js';
|
||||
export type { Agent, CreateAgentData, UpdateAgentData } from './agents.js';
|
||||
|
||||
199
frontend/src/lib/stores/agents.svelte.ts
Normal file
199
frontend/src/lib/stores/agents.svelte.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Agents state management using Svelte 5 runes
|
||||
* Manages agent configurations with IndexedDB persistence
|
||||
*/
|
||||
|
||||
import {
|
||||
getAllAgents,
|
||||
getAgent,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
assignAgentToProject,
|
||||
removeAgentFromProject,
|
||||
getAgentsForProject,
|
||||
type Agent,
|
||||
type CreateAgentData,
|
||||
type UpdateAgentData
|
||||
} from '$lib/storage';
|
||||
|
||||
/** Agents state class with reactive properties */
|
||||
export class AgentsState {
|
||||
/** All available agents */
|
||||
agents = $state<Agent[]>([]);
|
||||
|
||||
/** Loading state */
|
||||
isLoading = $state(false);
|
||||
|
||||
/** Error state */
|
||||
error = $state<string | null>(null);
|
||||
|
||||
/** Promise that resolves when initial load is complete */
|
||||
private _readyPromise: Promise<void> | null = null;
|
||||
private _readyResolve: (() => void) | null = null;
|
||||
|
||||
/** Derived: agents sorted alphabetically by name */
|
||||
get sortedAgents(): Agent[] {
|
||||
return [...this.agents].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Create ready promise
|
||||
this._readyPromise = new Promise((resolve) => {
|
||||
this._readyResolve = resolve;
|
||||
});
|
||||
|
||||
// Load agents on initialization (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
this.load();
|
||||
} else {
|
||||
// SSR: resolve immediately
|
||||
this._readyResolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for initial load to complete */
|
||||
async ready(): Promise<void> {
|
||||
return this._readyPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all agents from IndexedDB
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const result = await getAllAgents();
|
||||
if (result.success) {
|
||||
this.agents = result.data;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load agents';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this._readyResolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent
|
||||
*/
|
||||
async add(data: CreateAgentData): Promise<Agent | null> {
|
||||
try {
|
||||
const result = await createAgent(data);
|
||||
if (result.success) {
|
||||
this.agents = [...this.agents, result.data];
|
||||
return result.data;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to create agent';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing agent
|
||||
*/
|
||||
async update(id: string, updates: UpdateAgentData): Promise<boolean> {
|
||||
try {
|
||||
const result = await updateAgent(id, updates);
|
||||
if (result.success) {
|
||||
this.agents = this.agents.map((a) => (a.id === id ? result.data : a));
|
||||
return true;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to update agent';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent
|
||||
*/
|
||||
async remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await deleteAgent(id);
|
||||
if (result.success) {
|
||||
this.agents = this.agents.filter((a) => a.id !== id);
|
||||
return true;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to delete agent';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an agent by ID
|
||||
*/
|
||||
get(id: string): Agent | undefined {
|
||||
return this.agents.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an agent to a project
|
||||
*/
|
||||
async assignToProject(agentId: string, projectId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await assignAgentToProject(agentId, projectId);
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to assign agent to project';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from a project
|
||||
*/
|
||||
async removeFromProject(agentId: string, projectId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await removeAgentFromProject(agentId, projectId);
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to remove agent from project';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agents assigned to a project
|
||||
*/
|
||||
async getForProject(projectId: string): Promise<Agent[]> {
|
||||
try {
|
||||
const result = await getAgentsForProject(projectId);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
this.error = result.error;
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to get agents for project';
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any error state
|
||||
*/
|
||||
clearError(): void {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton agents state instance */
|
||||
export const agentsState = new AgentsState();
|
||||
280
frontend/src/lib/stores/agents.test.ts
Normal file
280
frontend/src/lib/stores/agents.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* AgentsState store tests
|
||||
*
|
||||
* Tests the reactive state management for agents.
|
||||
* Uses fake-indexeddb for in-memory IndexedDB testing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { db, generateId } from '$lib/storage/db.js';
|
||||
|
||||
// Import after fake-indexeddb is set up
|
||||
let AgentsState: typeof import('./agents.svelte.js').AgentsState;
|
||||
let agentsState: InstanceType<typeof AgentsState>;
|
||||
|
||||
describe('AgentsState', () => {
|
||||
beforeEach(async () => {
|
||||
// Clear database
|
||||
await db.agents.clear();
|
||||
await db.projectAgents.clear();
|
||||
|
||||
// Dynamically import to get fresh state
|
||||
vi.resetModules();
|
||||
const module = await import('./agents.svelte.js');
|
||||
AgentsState = module.AgentsState;
|
||||
agentsState = new AgentsState();
|
||||
|
||||
// Wait for initial load to complete
|
||||
await agentsState.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.agents.clear();
|
||||
await db.projectAgents.clear();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('starts with empty agents array', async () => {
|
||||
expect(agentsState.agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads agents on construction in browser', async () => {
|
||||
// Pre-populate database
|
||||
await db.agents.add({
|
||||
id: generateId(),
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
promptId: null,
|
||||
enabledToolNames: [],
|
||||
preferredModel: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// Create fresh instance
|
||||
vi.resetModules();
|
||||
const module = await import('./agents.svelte.js');
|
||||
const freshState = new module.AgentsState();
|
||||
await freshState.ready();
|
||||
|
||||
expect(freshState.agents.length).toBe(1);
|
||||
expect(freshState.agents[0].name).toBe('Test Agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortedAgents', () => {
|
||||
it('returns agents sorted alphabetically by name', async () => {
|
||||
await agentsState.add({ name: 'Zeta', description: '' });
|
||||
await agentsState.add({ name: 'Alpha', description: '' });
|
||||
await agentsState.add({ name: 'Mid', description: '' });
|
||||
|
||||
const sorted = agentsState.sortedAgents;
|
||||
|
||||
expect(sorted[0].name).toBe('Alpha');
|
||||
expect(sorted[1].name).toBe('Mid');
|
||||
expect(sorted[2].name).toBe('Zeta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('adds agent to state', async () => {
|
||||
const agent = await agentsState.add({
|
||||
name: 'New Agent',
|
||||
description: 'Test description'
|
||||
});
|
||||
|
||||
expect(agent).not.toBeNull();
|
||||
expect(agentsState.agents.length).toBe(1);
|
||||
expect(agentsState.agents[0].name).toBe('New Agent');
|
||||
});
|
||||
|
||||
it('persists to storage', async () => {
|
||||
const agent = await agentsState.add({
|
||||
name: 'Persistent Agent',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Verify in database
|
||||
const stored = await db.agents.get(agent!.id);
|
||||
expect(stored).not.toBeUndefined();
|
||||
expect(stored!.name).toBe('Persistent Agent');
|
||||
});
|
||||
|
||||
it('returns agent with generated id and timestamps', async () => {
|
||||
const before = Date.now();
|
||||
const agent = await agentsState.add({
|
||||
name: 'Agent',
|
||||
description: ''
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
expect(agent).not.toBeNull();
|
||||
expect(agent!.id).toBeTruthy();
|
||||
expect(agent!.createdAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(agent!.createdAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates agent in state', async () => {
|
||||
const agent = await agentsState.add({ name: 'Original', description: '' });
|
||||
expect(agent).not.toBeNull();
|
||||
|
||||
const success = await agentsState.update(agent!.id, { name: 'Updated' });
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(agentsState.agents[0].name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('persists changes', async () => {
|
||||
const agent = await agentsState.add({ name: 'Original', description: '' });
|
||||
await agentsState.update(agent!.id, { description: 'New description' });
|
||||
|
||||
const stored = await db.agents.get(agent!.id);
|
||||
expect(stored!.description).toBe('New description');
|
||||
});
|
||||
|
||||
it('updates updatedAt timestamp', async () => {
|
||||
const agent = await agentsState.add({ name: 'Agent', description: '' });
|
||||
const originalUpdatedAt = agent!.updatedAt.getTime();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await agentsState.update(agent!.id, { name: 'Changed' });
|
||||
|
||||
expect(agentsState.agents[0].updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt);
|
||||
});
|
||||
|
||||
it('returns false for non-existent agent', async () => {
|
||||
const success = await agentsState.update('non-existent', { name: 'Updated' });
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes agent from state', async () => {
|
||||
const agent = await agentsState.add({ name: 'To Delete', description: '' });
|
||||
expect(agentsState.agents.length).toBe(1);
|
||||
|
||||
const success = await agentsState.remove(agent!.id);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(agentsState.agents.length).toBe(0);
|
||||
});
|
||||
|
||||
it('removes from storage', async () => {
|
||||
const agent = await agentsState.add({ name: 'To Delete', description: '' });
|
||||
await agentsState.remove(agent!.id);
|
||||
|
||||
const stored = await db.agents.get(agent!.id);
|
||||
expect(stored).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes project-agent associations', async () => {
|
||||
const agent = await agentsState.add({ name: 'Agent', description: '' });
|
||||
const projectId = generateId();
|
||||
|
||||
await agentsState.assignToProject(agent!.id, projectId);
|
||||
|
||||
// Verify assignment exists
|
||||
let assignments = await db.projectAgents.where('agentId').equals(agent!.id).toArray();
|
||||
expect(assignments.length).toBe(1);
|
||||
|
||||
await agentsState.remove(agent!.id);
|
||||
|
||||
// Verify assignment removed
|
||||
assignments = await db.projectAgents.where('agentId').equals(agent!.id).toArray();
|
||||
expect(assignments.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns agent by id', async () => {
|
||||
const agent = await agentsState.add({ name: 'Test', description: 'desc' });
|
||||
|
||||
const found = agentsState.get(agent!.id);
|
||||
|
||||
expect(found).not.toBeUndefined();
|
||||
expect(found!.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('returns undefined for missing id', async () => {
|
||||
const found = agentsState.get('non-existent');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('project relationships', () => {
|
||||
it('gets agents for specific project', async () => {
|
||||
const agent1 = await agentsState.add({ name: 'Agent 1', description: '' });
|
||||
const agent2 = await agentsState.add({ name: 'Agent 2', description: '' });
|
||||
const agent3 = await agentsState.add({ name: 'Agent 3', description: '' });
|
||||
|
||||
const projectId = generateId();
|
||||
const otherProjectId = generateId();
|
||||
|
||||
await agentsState.assignToProject(agent1!.id, projectId);
|
||||
await agentsState.assignToProject(agent2!.id, projectId);
|
||||
await agentsState.assignToProject(agent3!.id, otherProjectId);
|
||||
|
||||
const agents = await agentsState.getForProject(projectId);
|
||||
|
||||
expect(agents.length).toBe(2);
|
||||
const names = agents.map((a) => a.name).sort();
|
||||
expect(names).toEqual(['Agent 1', 'Agent 2']);
|
||||
});
|
||||
|
||||
it('assigns agent to project', async () => {
|
||||
const agent = await agentsState.add({ name: 'Agent', description: '' });
|
||||
const projectId = generateId();
|
||||
|
||||
const success = await agentsState.assignToProject(agent!.id, projectId);
|
||||
|
||||
expect(success).toBe(true);
|
||||
const agents = await agentsState.getForProject(projectId);
|
||||
expect(agents.length).toBe(1);
|
||||
});
|
||||
|
||||
it('removes agent from project', async () => {
|
||||
const agent = await agentsState.add({ name: 'Agent', description: '' });
|
||||
const projectId = generateId();
|
||||
|
||||
await agentsState.assignToProject(agent!.id, projectId);
|
||||
const success = await agentsState.removeFromProject(agent!.id, projectId);
|
||||
|
||||
expect(success).toBe(true);
|
||||
const agents = await agentsState.getForProject(projectId);
|
||||
expect(agents.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty array for project with no agents', async () => {
|
||||
const agents = await agentsState.getForProject(generateId());
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('sets error state on failure', async () => {
|
||||
// Force an error by updating non-existent agent
|
||||
await agentsState.update('non-existent', { name: 'Test' });
|
||||
|
||||
expect(agentsState.error).not.toBeNull();
|
||||
expect(agentsState.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('clears error state with clearError', async () => {
|
||||
await agentsState.update('non-existent', { name: 'Test' });
|
||||
expect(agentsState.error).not.toBeNull();
|
||||
|
||||
agentsState.clearError();
|
||||
|
||||
expect(agentsState.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('is false after load completes', async () => {
|
||||
expect(agentsState.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,15 @@ export class ConversationsState {
|
||||
this.update(id, { systemPromptId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the agent for a conversation
|
||||
* @param id The conversation ID
|
||||
* @param agentId The agent ID (or null to clear)
|
||||
*/
|
||||
setAgentId(id: string, agentId: string | null): void {
|
||||
this.update(id, { agentId });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Project-related methods
|
||||
// ========================================================================
|
||||
|
||||
@@ -13,6 +13,7 @@ export { SettingsState, settingsState } from './settings.svelte.js';
|
||||
export type { Prompt } from './prompts.svelte.js';
|
||||
export { VersionState, versionState } from './version.svelte.js';
|
||||
export { ProjectsState, projectsState } from './projects.svelte.js';
|
||||
export { AgentsState, agentsState } from './agents.svelte.js';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { GroupedConversations } from './conversations.svelte.js';
|
||||
|
||||
131
frontend/src/lib/stores/tools-agent.test.ts
Normal file
131
frontend/src/lib/stores/tools-agent.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tool definitions for agents - integration tests
|
||||
*
|
||||
* Tests getToolDefinitionsForAgent functionality
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
}
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Import after mocks are set up
|
||||
let toolsState: typeof import('./tools.svelte.js').toolsState;
|
||||
|
||||
describe('getToolDefinitionsForAgent', () => {
|
||||
beforeEach(async () => {
|
||||
localStorageMock.clear();
|
||||
vi.resetModules();
|
||||
|
||||
// Set up default tool enabled state (all tools enabled)
|
||||
localStorageMock.setItem('toolsEnabled', 'true');
|
||||
localStorageMock.setItem(
|
||||
'enabledTools',
|
||||
JSON.stringify({
|
||||
fetch_url: true,
|
||||
web_search: true,
|
||||
calculate: true,
|
||||
get_location: true,
|
||||
get_current_time: true
|
||||
})
|
||||
);
|
||||
|
||||
const module = await import('./tools.svelte.js');
|
||||
toolsState = module.toolsState;
|
||||
});
|
||||
|
||||
it('returns empty array when toolsEnabled is false', async () => {
|
||||
toolsState.toolsEnabled = false;
|
||||
|
||||
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only tools matching enabledToolNames', async () => {
|
||||
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
const names = result.map((t) => t.function.name).sort();
|
||||
expect(names).toEqual(['calculate', 'fetch_url']);
|
||||
});
|
||||
|
||||
it('includes both builtin and custom tools', async () => {
|
||||
// Add a custom tool
|
||||
toolsState.addCustomTool({
|
||||
name: 'my_custom_tool',
|
||||
description: 'A custom tool',
|
||||
implementation: 'javascript',
|
||||
code: 'return args;',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string' }
|
||||
},
|
||||
required: ['input']
|
||||
},
|
||||
enabled: true
|
||||
});
|
||||
|
||||
const result = toolsState.getToolDefinitionsForAgent([
|
||||
'fetch_url',
|
||||
'my_custom_tool'
|
||||
]);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
const names = result.map((t) => t.function.name).sort();
|
||||
expect(names).toEqual(['fetch_url', 'my_custom_tool']);
|
||||
});
|
||||
|
||||
it('returns empty array for empty enabledToolNames', async () => {
|
||||
const result = toolsState.getToolDefinitionsForAgent([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores tool names that do not exist', async () => {
|
||||
const result = toolsState.getToolDefinitionsForAgent([
|
||||
'fetch_url',
|
||||
'nonexistent_tool',
|
||||
'calculate'
|
||||
]);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
const names = result.map((t) => t.function.name).sort();
|
||||
expect(names).toEqual(['calculate', 'fetch_url']);
|
||||
});
|
||||
|
||||
it('respects tool enabled state for included tools', async () => {
|
||||
// Disable calculate tool
|
||||
toolsState.setToolEnabled('calculate', false);
|
||||
|
||||
const result = toolsState.getToolDefinitionsForAgent(['fetch_url', 'calculate']);
|
||||
|
||||
// calculate is disabled, so it should not be included
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].function.name).toBe('fetch_url');
|
||||
});
|
||||
|
||||
it('returns all tools when null is passed (no agent)', async () => {
|
||||
const withAgent = toolsState.getToolDefinitionsForAgent(['fetch_url']);
|
||||
const withoutAgent = toolsState.getToolDefinitionsForAgent(null);
|
||||
|
||||
expect(withAgent.length).toBe(1);
|
||||
expect(withoutAgent.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,57 @@ class ToolsState {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definitions filtered by an agent's enabled tool names.
|
||||
* When null is passed, returns all enabled tools (no agent filtering).
|
||||
*
|
||||
* @param enabledToolNames - Array of tool names the agent can use, or null for all tools
|
||||
* @returns Tool definitions that match both the agent's list and are globally enabled
|
||||
*/
|
||||
getToolDefinitionsForAgent(enabledToolNames: string[] | null): ToolDefinition[] {
|
||||
if (!this.toolsEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If null, return all enabled tools (no agent filtering)
|
||||
if (enabledToolNames === null) {
|
||||
return this.getEnabledToolDefinitions();
|
||||
}
|
||||
|
||||
// If empty array, return no tools
|
||||
if (enabledToolNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const toolNameSet = new Set(enabledToolNames);
|
||||
const result: ToolDefinition[] = [];
|
||||
|
||||
// Filter builtin tools
|
||||
const builtinDefs = toolRegistry.getDefinitions();
|
||||
for (const def of builtinDefs) {
|
||||
const name = def.function.name;
|
||||
if (toolNameSet.has(name) && this.isToolEnabled(name)) {
|
||||
result.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter custom tools
|
||||
for (const custom of this.customTools) {
|
||||
if (toolNameSet.has(custom.name) && custom.enabled && this.isToolEnabled(custom.name)) {
|
||||
result.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: custom.name,
|
||||
description: custom.description,
|
||||
parameters: custom.parameters
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool definitions with their enabled state
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Conversation {
|
||||
systemPromptId?: string | null;
|
||||
/** Optional project ID this conversation belongs to */
|
||||
projectId?: string | null;
|
||||
/** Optional agent ID for this conversation (determines prompt and tools) */
|
||||
agentId?: string | null;
|
||||
/** Auto-generated conversation summary for cross-chat context */
|
||||
summary?: string | null;
|
||||
/** Timestamp when summary was last updated */
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
ModelsTab,
|
||||
PromptsTab,
|
||||
ToolsTab,
|
||||
AgentsTab,
|
||||
KnowledgeTab,
|
||||
MemoryTab,
|
||||
type SettingsTab
|
||||
@@ -41,6 +42,8 @@
|
||||
<PromptsTab />
|
||||
{:else if activeTab === 'tools'}
|
||||
<ToolsTab />
|
||||
{:else if activeTab === 'agents'}
|
||||
<AgentsTab />
|
||||
{:else if activeTab === 'knowledge'}
|
||||
<KnowledgeTab />
|
||||
{:else if activeTab === 'memory'}
|
||||
|
||||
Reference in New Issue
Block a user