Files
vessel/frontend/src/lib/storage/conversations.ts
vikingowl 9b4eeaff2a 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
2026-01-22 12:02:13 +01:00

454 lines
12 KiB
TypeScript

/**
* Conversation CRUD operations for IndexedDB storage
*/
import { db, withErrorHandling, generateId } from './db.js';
import type { StoredConversation, StorageResult } from './db.js';
import type { Conversation, ConversationFull, MessageNode } from '../types/index.js';
import { getMessagesForConversation, getMessageTree, deleteMessagesForConversation } from './messages.js';
import { markForSync } from './sync.js';
/**
* Converts stored conversation to domain type
*/
function toDomainConversation(stored: StoredConversation): Conversation {
return {
id: stored.id,
title: stored.title,
model: stored.model,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt),
isPinned: stored.isPinned,
isArchived: stored.isArchived,
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
};
}
/**
* Converts domain conversation to stored type
*/
function toStoredConversation(
conversation: Omit<Conversation, 'createdAt' | 'updatedAt'> & {
createdAt?: Date;
updatedAt?: Date;
}
): StoredConversation {
const now = Date.now();
return {
id: conversation.id,
title: conversation.title,
model: conversation.model,
createdAt: conversation.createdAt?.getTime() ?? now,
updatedAt: conversation.updatedAt?.getTime() ?? now,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
messageCount: conversation.messageCount,
systemPromptId: conversation.systemPromptId ?? null
};
}
/**
* Get all non-archived conversations, sorted by updatedAt descending
* Pinned conversations are returned first
*/
export async function getAllConversations(): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const all = await db.conversations.toArray();
const filtered = all.filter((c) => !c.isArchived);
// Sort: pinned first, then by updatedAt descending
const sorted = filtered.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return sorted.map(toDomainConversation);
});
}
/**
* Get archived conversations, sorted by updatedAt descending
*/
export async function getArchivedConversations(): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const all = await db.conversations.toArray();
const archived = all.filter((c) => c.isArchived);
const sorted = archived.sort((a, b) => b.updatedAt - a.updatedAt);
return sorted.map(toDomainConversation);
});
}
/**
* Get a single conversation by ID (metadata only)
*/
export async function getConversation(id: string): Promise<StorageResult<Conversation | null>> {
return withErrorHandling(async () => {
const stored = await db.conversations.get(id);
return stored ? toDomainConversation(stored) : null;
});
}
/**
* Get a full conversation including all messages
*/
export async function getConversationFull(id: string): Promise<StorageResult<ConversationFull | null>> {
return withErrorHandling(async () => {
const stored = await db.conversations.get(id);
if (!stored) {
return null;
}
const messagesResult = await getMessagesForConversation(id);
if (!messagesResult.success) {
throw new Error((messagesResult as { success: false; error: string }).error);
}
const treeResult = await getMessageTree(id);
if (!treeResult.success) {
throw new Error((treeResult as { success: false; error: string }).error);
}
const { rootMessageId, activePath } = treeResult.data;
return {
...toDomainConversation(stored),
messages: messagesResult.data,
activePath,
rootMessageId
};
});
}
/**
* Create a new conversation
*/
export async function createConversation(
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount' | 'summary' | 'summaryUpdatedAt'>
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const id = generateId();
const now = Date.now();
const stored: StoredConversation = {
id,
title: data.title,
model: data.model,
createdAt: now,
updatedAt: now,
isPinned: data.isPinned ?? false,
isArchived: data.isArchived ?? false,
messageCount: 0,
syncVersion: 1,
systemPromptId: data.systemPromptId ?? null,
projectId: data.projectId ?? null
};
await db.conversations.add(stored);
// Queue for backend sync
await markForSync('conversation', id, 'create');
return toDomainConversation(stored);
});
}
/**
* Update conversation metadata
*/
export async function updateConversation(
id: string,
data: Partial<Omit<StoredConversation, 'id' | 'createdAt'>>
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(id);
if (!existing) {
throw new Error(`Conversation not found: ${id}`);
}
const updated: StoredConversation = {
...existing,
...data,
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
// Queue for backend sync
await markForSync('conversation', id, 'update');
return toDomainConversation(updated);
});
}
/**
* Delete a conversation and all its messages and attachments
*/
export async function deleteConversation(id: string): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.transaction('rw', [db.conversations, db.messages, db.attachments], async () => {
// Delete all attachments for messages in this conversation
const messages = await db.messages.where('conversationId').equals(id).toArray();
const messageIds = messages.map((m) => m.id);
if (messageIds.length > 0) {
await db.attachments.where('messageId').anyOf(messageIds).delete();
}
// Delete all messages
await deleteMessagesForConversation(id);
// Delete the conversation
await db.conversations.delete(id);
});
// Queue for backend sync (after transaction completes)
await markForSync('conversation', id, 'delete');
});
}
/**
* Toggle pin status for a conversation
*/
export async function pinConversation(id: string): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(id);
if (!existing) {
throw new Error(`Conversation not found: ${id}`);
}
const updated: StoredConversation = {
...existing,
isPinned: !existing.isPinned,
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
// Queue for backend sync
await markForSync('conversation', id, 'update');
return toDomainConversation(updated);
});
}
/**
* Archive a conversation (or unarchive if already archived)
*/
export async function archiveConversation(id: string): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(id);
if (!existing) {
throw new Error(`Conversation not found: ${id}`);
}
const updated: StoredConversation = {
...existing,
isArchived: !existing.isArchived,
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
// Queue for backend sync
await markForSync('conversation', id, 'update');
return toDomainConversation(updated);
});
}
/**
* Update the message count for a conversation
* Called internally when messages are added or removed
*/
export async function updateMessageCount(
conversationId: string,
delta: number
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(conversationId);
if (!existing) {
throw new Error(`Conversation not found: ${conversationId}`);
}
await db.conversations.update(conversationId, {
messageCount: Math.max(0, existing.messageCount + delta),
updatedAt: Date.now()
});
});
}
/**
* Update the system prompt for a conversation
*/
export async function updateSystemPrompt(
conversationId: string,
systemPromptId: string | null
): Promise<StorageResult<Conversation>> {
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
*/
export async function searchConversations(query: string): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const lowerQuery = query.toLowerCase();
const all = await db.conversations.toArray();
const matching = all
.filter((c) => !c.isArchived && c.title.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return matching.map(toDomainConversation);
});
}
// ============================================================================
// Project-related operations
// ============================================================================
/**
* Get all conversations for a specific project
*/
export async function getConversationsForProject(
projectId: string
): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const conversations = await db.conversations
.where('projectId')
.equals(projectId)
.toArray();
const sorted = conversations
.filter((c) => !c.isArchived)
.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return sorted.map(toDomainConversation);
});
}
/**
* Get all conversations without a project (ungrouped)
*/
export async function getConversationsWithoutProject(): Promise<StorageResult<Conversation[]>> {
return withErrorHandling(async () => {
const all = await db.conversations.toArray();
const ungrouped = all
.filter((c) => !c.isArchived && (!c.projectId || c.projectId === null))
.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.updatedAt - a.updatedAt;
});
return ungrouped.map(toDomainConversation);
});
}
/**
* Move a conversation to a project (or remove from project if null)
*/
export async function moveConversationToProject(
conversationId: string,
projectId: string | null
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(conversationId);
if (!existing) {
throw new Error(`Conversation not found: ${conversationId}`);
}
const updated: StoredConversation = {
...existing,
projectId: projectId,
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
await markForSync('conversation', conversationId, 'update');
return toDomainConversation(updated);
});
}
/**
* Update conversation summary (for cross-chat context)
*/
export async function updateConversationSummary(
conversationId: string,
summary: string
): Promise<StorageResult<Conversation>> {
return withErrorHandling(async () => {
const existing = await db.conversations.get(conversationId);
if (!existing) {
throw new Error(`Conversation not found: ${conversationId}`);
}
const updated: StoredConversation = {
...existing,
summary,
summaryUpdatedAt: Date.now(),
updatedAt: Date.now(),
syncVersion: (existing.syncVersion ?? 0) + 1
};
await db.conversations.put(updated);
return toDomainConversation(updated);
});
}
/**
* Get conversation summaries for all conversations in a project (excluding current)
*/
export async function getProjectConversationSummaries(
projectId: string,
excludeConversationId?: string
): Promise<StorageResult<Array<{ id: string; title: string; summary: string; updatedAt: Date }>>> {
return withErrorHandling(async () => {
const conversations = await db.conversations
.where('projectId')
.equals(projectId)
.toArray();
return conversations
.filter((c) => !c.isArchived && c.summary && c.id !== excludeConversationId)
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((c) => ({
id: c.id,
title: c.title,
summary: c.summary!,
updatedAt: new Date(c.summaryUpdatedAt ?? c.updatedAt)
}));
});
}