From 5e6994f415de6d82bfba4e6b7f22679a8b10d492 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 7 Jan 2026 14:36:12 +0100 Subject: [PATCH] feat: add projects feature for organizing conversations Add ChatGPT-style projects with cross-chat context sharing: - Database schema v6 with projects, projectLinks, chatChunks tables - Project CRUD operations and storage layer - ProjectsState store with Svelte 5 runes - Cross-chat context services (summaries, chat indexing, context assembly) - Project context injection into ChatWindow system prompt - ProjectFolder collapsible component in sidebar - ProjectModal for create/edit with Settings, Instructions, Links tabs - MoveToProjectModal for moving conversations between projects - "New Project" button in sidebar - "Move to Project" action on conversation items Conversations in a project share awareness through: - Project instructions injected into system prompt - Summaries of other project conversations - RAG search across project chat history (stub) - Reference links --- .../src/lib/components/chat/ChatWindow.svelte | 32 +- .../components/layout/ConversationItem.svelte | 44 +- .../components/layout/ConversationList.svelte | 68 ++- .../components/layout/ProjectFolder.svelte | 124 +++++ .../src/lib/components/layout/Sidenav.svelte | 58 ++- .../projects/MoveToProjectModal.svelte | 176 +++++++ .../components/projects/ProjectModal.svelte | 457 ++++++++++++++++++ frontend/src/lib/services/chat-indexer.ts | 191 ++++++++ .../src/lib/services/conversation-summary.ts | 157 ++++++ frontend/src/lib/services/project-context.ts | 174 +++++++ frontend/src/lib/storage/conversations.ts | 135 +++++- frontend/src/lib/storage/db.ts | 89 ++++ frontend/src/lib/storage/projects.ts | 310 ++++++++++++ .../src/lib/stores/conversations.svelte.ts | 60 +++ frontend/src/lib/stores/index.ts | 1 + frontend/src/lib/stores/projects.svelte.ts | 148 ++++++ frontend/src/lib/types/conversation.ts | 6 + frontend/src/routes/+layout.svelte | 5 +- 18 files changed, 2211 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/components/layout/ProjectFolder.svelte create mode 100644 frontend/src/lib/components/projects/MoveToProjectModal.svelte create mode 100644 frontend/src/lib/components/projects/ProjectModal.svelte create mode 100644 frontend/src/lib/services/chat-indexer.ts create mode 100644 frontend/src/lib/services/conversation-summary.ts create mode 100644 frontend/src/lib/services/project-context.ts create mode 100644 frontend/src/lib/storage/projects.ts create mode 100644 frontend/src/lib/stores/projects.svelte.ts diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index ed5bfea..f68a28a 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -36,6 +36,7 @@ import SystemPromptSelector from './SystemPromptSelector.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'; /** * Props interface for ChatWindow @@ -156,6 +157,27 @@ } } + /** + * Retrieve project context (instructions, summaries, chat history) + * Only applicable when the conversation belongs to a project + */ + async function retrieveProjectContext(query: string): Promise { + const projectId = conversation?.projectId; + const conversationId = chatState.conversationId; + + if (!projectId || !conversationId) return null; + + try { + const context = await buildProjectContext(projectId, conversationId, query); + if (!hasProjectContext(context)) return null; + + return formatProjectContextForPrompt(context); + } catch (error) { + console.error('[ProjectContext] Failed to retrieve context:', error); + return null; + } + } + /** * Convert OllamaToolCall to the format expected by tool executor * Ollama doesn't provide IDs, so we generate them @@ -653,8 +675,16 @@ systemParts.push(resolvedPrompt.content); } - // RAG: Retrieve relevant context for the last user message + // Project context: Retrieve instructions, summaries, and chat history const lastUserMessage = messages.filter(m => m.role === 'user').pop(); + if (lastUserMessage && conversation?.projectId) { + const projectContext = await retrieveProjectContext(lastUserMessage.content); + if (projectContext) { + systemParts.push(projectContext); + } + } + + // RAG: Retrieve relevant context for the last user message if (lastUserMessage && ragEnabled && hasKnowledgeBase) { const ragContext = await retrieveRagContext(lastUserMessage.content); if (ragContext) { diff --git a/frontend/src/lib/components/layout/ConversationItem.svelte b/frontend/src/lib/components/layout/ConversationItem.svelte index e886bb2..1819543 100644 --- a/frontend/src/lib/components/layout/ConversationItem.svelte +++ b/frontend/src/lib/components/layout/ConversationItem.svelte @@ -1,13 +1,14 @@
- {#if conversationsState.grouped.length === 0} + {#if !hasAnyContent && conversationsState.grouped.length === 0}
{:else} - - {#each conversationsState.grouped as { group, conversations } (group)} -
- + + {#if projectsState.sortedProjects.length > 0} +

- {group} + Projects

- -
- {#each conversations as conversation (conversation.id)} - {/each}
+ {/if} + + + {#each conversationsState.grouped as { group, conversations } (group)} + {@const ungroupedInGroup = conversations.filter(c => !c.projectId)} + {#if ungroupedInGroup.length > 0} +
+ +

+ {group} +

+ + +
+ {#each ungroupedInGroup as conversation (conversation.id)} + + {/each} +
+
+ {/if} {/each} diff --git a/frontend/src/lib/components/layout/ProjectFolder.svelte b/frontend/src/lib/components/layout/ProjectFolder.svelte new file mode 100644 index 0000000..343f6cb --- /dev/null +++ b/frontend/src/lib/components/layout/ProjectFolder.svelte @@ -0,0 +1,124 @@ + + +
+ +
e.key === 'Enter' && handleToggle(e as unknown as MouseEvent)} + class="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-theme-secondary/60" + > + + + + + + + + + + + + + {project.name} + + + {conversations.length} + + + + +
+ + + {#if isExpanded && conversations.length > 0} +
+ {#each conversations as conversation (conversation.id)} + + {/each} +
+ {/if} + + + {#if isExpanded && conversations.length === 0} +
+

+ No conversations yet +

+
+ {/if} +
diff --git a/frontend/src/lib/components/layout/Sidenav.svelte b/frontend/src/lib/components/layout/Sidenav.svelte index 8e1c025..ed74128 100644 --- a/frontend/src/lib/components/layout/Sidenav.svelte +++ b/frontend/src/lib/components/layout/Sidenav.svelte @@ -1,16 +1,36 @@ @@ -38,9 +58,34 @@ + +
+ +
+
- +
@@ -158,3 +203,10 @@
+ + + diff --git a/frontend/src/lib/components/projects/MoveToProjectModal.svelte b/frontend/src/lib/components/projects/MoveToProjectModal.svelte new file mode 100644 index 0000000..815e643 --- /dev/null +++ b/frontend/src/lib/components/projects/MoveToProjectModal.svelte @@ -0,0 +1,176 @@ + + + + +{#if isOpen} + + +{/if} diff --git a/frontend/src/lib/components/projects/ProjectModal.svelte b/frontend/src/lib/components/projects/ProjectModal.svelte new file mode 100644 index 0000000..b5f38b0 --- /dev/null +++ b/frontend/src/lib/components/projects/ProjectModal.svelte @@ -0,0 +1,457 @@ + + + + +{#if isOpen} + + +{/if} diff --git a/frontend/src/lib/services/chat-indexer.ts b/frontend/src/lib/services/chat-indexer.ts new file mode 100644 index 0000000..f7fc87a --- /dev/null +++ b/frontend/src/lib/services/chat-indexer.ts @@ -0,0 +1,191 @@ +/** + * Chat Indexer Service + * Indexes conversation messages for RAG search across project chats + * + * Note: Full embedding-based search requires an embedding model. + * This is a placeholder that will be enhanced when embedding support is added. + */ + +import { db } from '$lib/storage/db.js'; +import type { StoredChatChunk } from '$lib/storage/db.js'; +import type { Message } from '$lib/types/chat.js'; +import { generateId } from '$lib/storage/db.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface IndexingOptions { + /** Embedding model to use (e.g., 'nomic-embed-text') */ + embeddingModel?: string; + /** Base URL for Ollama API */ + baseUrl?: string; + /** Only index assistant messages (recommended) */ + assistantOnly?: boolean; + /** Minimum content length to index */ + minContentLength?: number; +} + +export interface ChatSearchResult { + conversationId: string; + conversationTitle: string; + messageId: string; + content: string; + similarity: number; +} + +// ============================================================================ +// Indexing Functions +// ============================================================================ + +/** + * Index messages from a conversation for RAG search + * Note: Currently stores messages without embeddings. + * Embeddings can be added later when an embedding model is available. + */ +export async function indexConversationMessages( + conversationId: string, + projectId: string, + messages: Message[], + options: IndexingOptions = {} +): Promise { + const { + assistantOnly = true, + minContentLength = 50 + } = options; + + // Filter messages to index + const messagesToIndex = messages.filter((m) => { + if (assistantOnly && m.role !== 'assistant') return false; + if (m.content.length < minContentLength) return false; + if (m.hidden) return false; + return true; + }); + + if (messagesToIndex.length === 0) { + return 0; + } + + // Create chunks (without embeddings for now) + const chunks: StoredChatChunk[] = messagesToIndex.map((m, index) => ({ + id: generateId(), + conversationId, + projectId, + messageId: `${conversationId}-${index}`, // Placeholder message ID + role: m.role as 'user' | 'assistant', + content: m.content.slice(0, 2000), // Limit content length + embedding: [], // Empty for now - will be populated when embedding support is added + createdAt: Date.now() + })); + + // Store chunks + await db.chatChunks.bulkAdd(chunks); + + return chunks.length; +} + +/** + * Re-index a conversation when it moves to/from a project + */ +export async function reindexConversationForProject( + conversationId: string, + newProjectId: string | null +): Promise { + // Remove existing chunks for this conversation + await db.chatChunks.where('conversationId').equals(conversationId).delete(); + + // If moving to a project, chunks will be re-created when needed + // For now, this is a placeholder - actual re-indexing would happen + // when the conversation is opened or when summaries are generated +} + +/** + * Remove all indexed chunks for a conversation + */ +export async function removeConversationFromIndex(conversationId: string): Promise { + await db.chatChunks.where('conversationId').equals(conversationId).delete(); +} + +/** + * Remove all indexed chunks for a project + */ +export async function removeProjectFromIndex(projectId: string): Promise { + await db.chatChunks.where('projectId').equals(projectId).delete(); +} + +// ============================================================================ +// Search Functions (Placeholder) +// ============================================================================ + +/** + * Search indexed chat history within a project + * Note: Currently returns empty results as embeddings are not yet implemented. + * This will be enhanced when embedding support is added. + */ +export async function searchChatHistory( + projectId: string, + query: string, + excludeConversationId?: string, + topK: number = 5, + threshold: number = 0.5 +): Promise { + // Get all chunks for this project + const chunks = await db.chatChunks + .where('projectId') + .equals(projectId) + .toArray(); + + // Filter out excluded conversation + const relevantChunks = excludeConversationId + ? chunks.filter((c) => c.conversationId !== excludeConversationId) + : chunks; + + if (relevantChunks.length === 0) { + return []; + } + + // TODO: Implement embedding-based similarity search + // For now, return empty results + // When embeddings are available: + // 1. Generate embedding for query + // 2. Calculate cosine similarity with each chunk + // 3. Return top K results above threshold + + return []; +} + +// ============================================================================ +// Statistics +// ============================================================================ + +/** + * Get indexing statistics for a project + */ +export async function getProjectIndexStats(projectId: string): Promise<{ + totalChunks: number; + conversationCount: number; +}> { + const chunks = await db.chatChunks + .where('projectId') + .equals(projectId) + .toArray(); + + const conversationIds = new Set(chunks.map((c) => c.conversationId)); + + return { + totalChunks: chunks.length, + conversationCount: conversationIds.size + }; +} + +/** + * Check if a conversation is indexed + */ +export async function isConversationIndexed(conversationId: string): Promise { + const count = await db.chatChunks + .where('conversationId') + .equals(conversationId) + .count(); + + return count > 0; +} diff --git a/frontend/src/lib/services/conversation-summary.ts b/frontend/src/lib/services/conversation-summary.ts new file mode 100644 index 0000000..41790ef --- /dev/null +++ b/frontend/src/lib/services/conversation-summary.ts @@ -0,0 +1,157 @@ +/** + * Conversation Summary Service + * Generates and manages conversation summaries for cross-chat context + */ + +import { db } from '$lib/storage/db.js'; +import { updateConversationSummary } from '$lib/storage/conversations.js'; +import type { Message } from '$lib/types/chat.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SummaryGenerationOptions { + /** Model to use for summary generation */ + model: string; + /** Base URL for Ollama API */ + baseUrl?: string; + /** Maximum messages to include in summary context */ + maxMessages?: number; +} + +// ============================================================================ +// Summary Generation +// ============================================================================ + +/** + * Generate a summary for a conversation using the LLM + * @param conversationId - The conversation to summarize + * @param messages - The messages to summarize + * @param options - Generation options + * @returns The generated summary text + */ +export async function generateConversationSummary( + conversationId: string, + messages: Message[], + options: SummaryGenerationOptions +): Promise { + const { model, baseUrl = 'http://localhost:11434', maxMessages = 20 } = options; + + // Filter to user and assistant messages only + const relevantMessages = messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .slice(-maxMessages); // Take last N messages + + if (relevantMessages.length === 0) { + return ''; + } + + // Format messages for the prompt + const conversationText = relevantMessages + .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`) + .join('\n\n'); + + const prompt = `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise. + +Conversation: +${conversationText} + +Summary:`; + + try { + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + prompt, + stream: false, + options: { + temperature: 0.3, + num_predict: 150 + } + }) + }); + + if (!response.ok) { + console.error('[ConversationSummary] Failed to generate summary:', response.statusText); + return ''; + } + + const data = await response.json(); + return data.response?.trim() || ''; + } catch (error) { + console.error('[ConversationSummary] Error generating summary:', error); + return ''; + } +} + +/** + * Generate and save a summary for a conversation + */ +export async function generateAndSaveSummary( + conversationId: string, + messages: Message[], + options: SummaryGenerationOptions +): Promise { + const summary = await generateConversationSummary(conversationId, messages, options); + + if (!summary) { + return false; + } + + const result = await updateConversationSummary(conversationId, summary); + return result.success; +} + +/** + * Check if a conversation needs its summary updated + * @param conversationId - The conversation to check + * @param currentMessageCount - Current number of messages + * @param threshold - Number of new messages before updating (default: 10) + */ +export async function needsSummaryUpdate( + conversationId: string, + currentMessageCount: number, + threshold: number = 10 +): Promise { + const conversation = await db.conversations.get(conversationId); + + if (!conversation) { + return false; + } + + // No summary yet - needs one if there are enough messages + if (!conversation.summary) { + return currentMessageCount >= 4; // At least 2 exchanges + } + + // Check if enough new messages since last summary + // This is a simple heuristic - could be improved with actual message tracking + const lastSummaryTime = conversation.summaryUpdatedAt || conversation.createdAt; + const timeSinceLastSummary = Date.now() - lastSummaryTime; + + // Update if more than 30 minutes old and conversation has grown + return timeSinceLastSummary > 30 * 60 * 1000 && currentMessageCount >= 6; +} + +/** + * Get the summary prompt for manual triggering + */ +export function getSummaryPrompt(messages: Message[], maxMessages: number = 20): string { + const relevantMessages = messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .slice(-maxMessages); + + const conversationText = relevantMessages + .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 500)}`) + .join('\n\n'); + + return `Summarize this conversation in 2-3 sentences. Focus on the main topics discussed, any decisions made, and key outcomes. Be concise. + +Conversation: +${conversationText} + +Summary:`; +} diff --git a/frontend/src/lib/services/project-context.ts b/frontend/src/lib/services/project-context.ts new file mode 100644 index 0000000..edcb36f --- /dev/null +++ b/frontend/src/lib/services/project-context.ts @@ -0,0 +1,174 @@ +/** + * Project Context Service + * Builds full project context for chat messages including: + * - Project instructions + * - Conversation summaries from other project chats + * - RAG search across project chat history + * - Project reference links + */ + +import { db } from '$lib/storage/db.js'; +import { getProjectConversationSummaries } from '$lib/storage/conversations.js'; +import type { ProjectLink } from '$lib/storage/projects.js'; +import { getProjectLinks } from '$lib/storage/projects.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ConversationSummary { + id: string; + title: string; + summary: string; + updatedAt: Date; +} + +export interface ChatHistoryResult { + conversationId: string; + conversationTitle: string; + content: string; + similarity: number; +} + +export interface ProjectContext { + /** Project instructions to inject into system prompt */ + instructions: string | null; + /** Summaries of other conversations in the project */ + conversationSummaries: ConversationSummary[]; + /** Relevant snippets from chat history RAG search */ + relevantChatHistory: ChatHistoryResult[]; + /** Reference links for the project */ + links: ProjectLink[]; +} + +// ============================================================================ +// Main Context Builder +// ============================================================================ + +/** + * Build full project context for a chat message + * @param projectId - The project ID + * @param currentConversationId - The current conversation (excluded from summaries/RAG) + * @param userQuery - The user's message (used for RAG search) + * @returns ProjectContext with all relevant context + */ +export async function buildProjectContext( + projectId: string, + currentConversationId: string, + userQuery: string +): Promise { + // Fetch project data in parallel + const [project, summariesResult, linksResult, chatHistory] = await Promise.all([ + db.projects.get(projectId), + getProjectConversationSummaries(projectId, currentConversationId), + getProjectLinks(projectId), + searchProjectChatHistory(projectId, userQuery, currentConversationId, 3) + ]); + + const summaries = summariesResult.success ? summariesResult.data : []; + const links = linksResult.success ? linksResult.data : []; + + return { + instructions: project?.instructions || null, + conversationSummaries: summaries.map((s) => ({ + id: s.id, + title: s.title, + summary: s.summary, + updatedAt: s.updatedAt + })), + relevantChatHistory: chatHistory, + links + }; +} + +// ============================================================================ +// Chat History RAG Search +// ============================================================================ + +/** + * Search across project chat history using embeddings + * Returns relevant snippets from other conversations in the project + */ +export async function searchProjectChatHistory( + projectId: string, + query: string, + excludeConversationId?: string, + topK: number = 3, + threshold: number = 0.5 +): Promise { + // Get all chat chunks for this project + const chunks = await db.chatChunks + .where('projectId') + .equals(projectId) + .toArray(); + + // Filter out current conversation + const relevantChunks = excludeConversationId + ? chunks.filter((c) => c.conversationId !== excludeConversationId) + : chunks; + + if (relevantChunks.length === 0) { + return []; + } + + // For now, return empty - embeddings require Ollama API + // This will be populated when chat-indexer.ts is implemented + // and conversations are indexed + return []; +} + +// ============================================================================ +// Context Formatting +// ============================================================================ + +/** + * Format project context for injection into system prompt + */ +export function formatProjectContextForPrompt(context: ProjectContext): string { + const parts: string[] = []; + + // Project instructions + if (context.instructions && context.instructions.trim()) { + parts.push(`## Project Instructions\n${context.instructions}`); + } + + // Conversation summaries + if (context.conversationSummaries.length > 0) { + const summariesText = context.conversationSummaries + .slice(0, 5) // Limit to 5 most recent + .map((s) => `- **${s.title}**: ${s.summary}`) + .join('\n'); + parts.push(`## Previous Discussions in This Project\n${summariesText}`); + } + + // Relevant chat history (RAG results) + if (context.relevantChatHistory.length > 0) { + const historyText = context.relevantChatHistory + .map((h) => `From "${h.conversationTitle}":\n${h.content}`) + .join('\n\n---\n\n'); + parts.push(`## Relevant Context from Past Conversations\n${historyText}`); + } + + // Reference links + if (context.links.length > 0) { + const linksText = context.links + .slice(0, 5) // Limit to 5 links + .map((l) => `- [${l.title}](${l.url})${l.description ? `: ${l.description}` : ''}`) + .join('\n'); + parts.push(`## Project Reference Links\n${linksText}`); + } + + return parts.join('\n\n'); +} + +/** + * Check if project context has any content worth injecting + */ +export function hasProjectContext(context: ProjectContext): boolean { + return ( + (context.instructions && context.instructions.trim().length > 0) || + context.conversationSummaries.length > 0 || + context.relevantChatHistory.length > 0 || + context.links.length > 0 + ); +} diff --git a/frontend/src/lib/storage/conversations.ts b/frontend/src/lib/storage/conversations.ts index e86ed0e..dfec666 100644 --- a/frontend/src/lib/storage/conversations.ts +++ b/frontend/src/lib/storage/conversations.ts @@ -21,7 +21,10 @@ function toDomainConversation(stored: StoredConversation): Conversation { isPinned: stored.isPinned, isArchived: stored.isArchived, messageCount: stored.messageCount, - systemPromptId: stored.systemPromptId ?? null + systemPromptId: stored.systemPromptId ?? null, + projectId: stored.projectId ?? null, + summary: stored.summary ?? null, + summaryUpdatedAt: stored.summaryUpdatedAt ? new Date(stored.summaryUpdatedAt) : null }; } @@ -126,7 +129,7 @@ export async function getConversationFull(id: string): Promise + data: Omit ): Promise> { return withErrorHandling(async () => { const id = generateId(); @@ -142,7 +145,8 @@ export async function createConversation( isArchived: data.isArchived ?? false, messageCount: 0, syncVersion: 1, - systemPromptId: data.systemPromptId ?? null + systemPromptId: data.systemPromptId ?? null, + projectId: data.projectId ?? null }; await db.conversations.add(stored); @@ -311,3 +315,128 @@ export async function searchConversations(query: string): Promise> { + 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> { + 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> { + 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> { + 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>> { + 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) + })); + }); +} diff --git a/frontend/src/lib/storage/db.ts b/frontend/src/lib/storage/db.ts index 8ae5851..a39e385 100644 --- a/frontend/src/lib/storage/db.ts +++ b/frontend/src/lib/storage/db.ts @@ -21,6 +21,12 @@ export interface StoredConversation { syncVersion?: number; /** Optional system prompt ID for this conversation */ systemPromptId?: string | null; + /** Optional project ID this conversation belongs to */ + projectId?: string | null; + /** Auto-generated conversation summary for cross-chat context */ + summary?: string | null; + /** Timestamp when summary was last updated */ + summaryUpdatedAt?: number | null; } /** @@ -38,6 +44,12 @@ export interface ConversationRecord { syncVersion?: number; /** Optional system prompt ID for this conversation */ systemPromptId?: string | null; + /** Optional project ID this conversation belongs to */ + projectId?: string | null; + /** Auto-generated conversation summary for cross-chat context */ + summary?: string | null; + /** Timestamp when summary was last updated */ + summaryUpdatedAt?: Date | null; } /** @@ -143,6 +155,8 @@ export interface StoredDocument { updatedAt: number; chunkCount: number; embeddingModel: string; + /** Optional project ID - if set, document is project-scoped */ + projectId?: string | null; } /** @@ -201,6 +215,55 @@ export interface StoredModelPromptMapping { updatedAt: number; } +// ============================================================================ +// Project-related interfaces (v6) +// ============================================================================ + +/** + * Project for organizing conversations with shared context + */ +export interface StoredProject { + id: string; + name: string; + description: string; + /** Instructions injected into system prompt for all project chats */ + instructions: string; + /** Hex color for UI display */ + color: string; + /** Whether folder is collapsed in sidebar */ + isCollapsed: boolean; + createdAt: number; + updatedAt: number; +} + +/** + * Reference link attached to a project + */ +export interface StoredProjectLink { + id: string; + projectId: string; + url: string; + title: string; + description: string; + createdAt: number; +} + +/** + * Chat message chunk with embedding for cross-chat RAG + * Enables searching across conversation history within a project + */ +export interface StoredChatChunk { + id: string; + conversationId: string; + /** Denormalized for efficient project-scoped queries */ + projectId: string; + messageId: string; + role: 'user' | 'assistant'; + content: string; + embedding: number[]; + createdAt: number; +} + /** * Ollama WebUI database class * Manages all local storage tables @@ -215,6 +278,10 @@ class OllamaDatabase extends Dexie { prompts!: Table; modelSystemPrompts!: Table; modelPromptMappings!: Table; + // Project-related tables (v6) + projects!: Table; + projectLinks!: Table; + chatChunks!: Table; constructor() { super('vessel'); @@ -283,6 +350,28 @@ class OllamaDatabase extends Dexie { // User-configured model-to-prompt mappings modelPromptMappings: 'id, modelName, promptId' }); + + // Version 6: Projects with cross-chat context sharing + // Adds: projects, project links, chat chunks for RAG, projectId on conversations/documents + this.version(6).stores({ + // Add projectId index for filtering conversations by project + conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId, projectId', + messages: 'id, conversationId, parentId, createdAt', + attachments: 'id, messageId', + syncQueue: 'id, entityType, createdAt', + // Add projectId index for project-scoped document RAG + documents: 'id, name, createdAt, updatedAt, projectId', + chunks: 'id, documentId', + prompts: 'id, name, isDefault, updatedAt', + modelSystemPrompts: 'modelName', + modelPromptMappings: 'id, modelName, promptId', + // Projects for organizing conversations + projects: 'id, name, createdAt, updatedAt', + // Reference links attached to projects + projectLinks: 'id, projectId, createdAt', + // Chat message chunks for cross-conversation RAG within projects + chatChunks: 'id, conversationId, projectId, createdAt' + }); } } diff --git a/frontend/src/lib/storage/projects.ts b/frontend/src/lib/storage/projects.ts new file mode 100644 index 0000000..32a6868 --- /dev/null +++ b/frontend/src/lib/storage/projects.ts @@ -0,0 +1,310 @@ +/** + * Project CRUD operations for IndexedDB storage + */ + +import { db, withErrorHandling, generateId } from './db.js'; +import type { StoredProject, StoredProjectLink, StorageResult } from './db.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface Project { + id: string; + name: string; + description: string; + instructions: string; + color: string; + isCollapsed: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ProjectLink { + id: string; + projectId: string; + url: string; + title: string; + description: string; + createdAt: Date; +} + +export interface CreateProjectData { + name: string; + description?: string; + instructions?: string; + color?: string; +} + +export interface UpdateProjectData { + name?: string; + description?: string; + instructions?: string; + color?: string; + isCollapsed?: boolean; +} + +export interface CreateProjectLinkData { + projectId: string; + url: string; + title: string; + description?: string; +} + +// ============================================================================ +// Converters +// ============================================================================ + +function toDomainProject(stored: StoredProject): Project { + return { + id: stored.id, + name: stored.name, + description: stored.description, + instructions: stored.instructions, + color: stored.color, + isCollapsed: stored.isCollapsed, + createdAt: new Date(stored.createdAt), + updatedAt: new Date(stored.updatedAt) + }; +} + +function toDomainProjectLink(stored: StoredProjectLink): ProjectLink { + return { + id: stored.id, + projectId: stored.projectId, + url: stored.url, + title: stored.title, + description: stored.description, + createdAt: new Date(stored.createdAt) + }; +} + +// Default project colors (tailwind-inspired) +const PROJECT_COLORS = [ + '#8b5cf6', // violet-500 + '#06b6d4', // cyan-500 + '#10b981', // emerald-500 + '#f59e0b', // amber-500 + '#ef4444', // red-500 + '#ec4899', // pink-500 + '#3b82f6', // blue-500 + '#84cc16' // lime-500 +]; + +function getRandomColor(): string { + return PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)]; +} + +// ============================================================================ +// Project CRUD +// ============================================================================ + +/** + * Get all projects, sorted by name + */ +export async function getAllProjects(): Promise> { + return withErrorHandling(async () => { + const all = await db.projects.toArray(); + const sorted = all.sort((a, b) => a.name.localeCompare(b.name)); + return sorted.map(toDomainProject); + }); +} + +/** + * Get a single project by ID + */ +export async function getProject(id: string): Promise> { + return withErrorHandling(async () => { + const stored = await db.projects.get(id); + return stored ? toDomainProject(stored) : null; + }); +} + +/** + * Create a new project + */ +export async function createProject(data: CreateProjectData): Promise> { + return withErrorHandling(async () => { + const now = Date.now(); + const stored: StoredProject = { + id: generateId(), + name: data.name, + description: data.description || '', + instructions: data.instructions || '', + color: data.color || getRandomColor(), + isCollapsed: false, + createdAt: now, + updatedAt: now + }; + + await db.projects.add(stored); + return toDomainProject(stored); + }); +} + +/** + * Update an existing project + */ +export async function updateProject( + id: string, + updates: UpdateProjectData +): Promise> { + return withErrorHandling(async () => { + const existing = await db.projects.get(id); + if (!existing) { + throw new Error(`Project not found: ${id}`); + } + + const updated: StoredProject = { + ...existing, + ...updates, + updatedAt: Date.now() + }; + + await db.projects.put(updated); + return toDomainProject(updated); + }); +} + +/** + * Delete a project and all associated data + * - Unlinks all conversations (sets projectId to null) + * - Deletes all project links + * - Deletes all project documents + * - Deletes all chat chunks for the project + */ +export async function deleteProject(id: string): Promise> { + return withErrorHandling(async () => { + await db.transaction('rw', [db.projects, db.projectLinks, db.conversations, db.documents, db.chatChunks], async () => { + // Unlink all conversations from this project + const conversations = await db.conversations.where('projectId').equals(id).toArray(); + for (const conv of conversations) { + await db.conversations.update(conv.id, { projectId: null }); + } + + // Delete all project links + await db.projectLinks.where('projectId').equals(id).delete(); + + // Delete all project documents (and their chunks) + const documents = await db.documents.where('projectId').equals(id).toArray(); + for (const doc of documents) { + await db.chunks.where('documentId').equals(doc.id).delete(); + } + await db.documents.where('projectId').equals(id).delete(); + + // Delete all chat chunks for this project + await db.chatChunks.where('projectId').equals(id).delete(); + + // Delete the project itself + await db.projects.delete(id); + }); + }); +} + +/** + * Toggle project collapse state + */ +export async function toggleProjectCollapse(id: string): Promise> { + return withErrorHandling(async () => { + const existing = await db.projects.get(id); + if (!existing) { + throw new Error(`Project not found: ${id}`); + } + + const newState = !existing.isCollapsed; + await db.projects.update(id, { isCollapsed: newState }); + return newState; + }); +} + +// ============================================================================ +// Project Links CRUD +// ============================================================================ + +/** + * Get all links for a project + */ +export async function getProjectLinks(projectId: string): Promise> { + return withErrorHandling(async () => { + const links = await db.projectLinks.where('projectId').equals(projectId).toArray(); + return links.map(toDomainProjectLink); + }); +} + +/** + * Add a link to a project + */ +export async function addProjectLink(data: CreateProjectLinkData): Promise> { + return withErrorHandling(async () => { + const stored: StoredProjectLink = { + id: generateId(), + projectId: data.projectId, + url: data.url, + title: data.title, + description: data.description || '', + createdAt: Date.now() + }; + + await db.projectLinks.add(stored); + return toDomainProjectLink(stored); + }); +} + +/** + * Update a project link + */ +export async function updateProjectLink( + id: string, + updates: Partial> +): Promise> { + return withErrorHandling(async () => { + const existing = await db.projectLinks.get(id); + if (!existing) { + throw new Error(`Project link not found: ${id}`); + } + + const updated: StoredProjectLink = { + ...existing, + ...updates + }; + + await db.projectLinks.put(updated); + return toDomainProjectLink(updated); + }); +} + +/** + * Delete a project link + */ +export async function deleteProjectLink(id: string): Promise> { + return withErrorHandling(async () => { + await db.projectLinks.delete(id); + }); +} + +// ============================================================================ +// Project Statistics +// ============================================================================ + +/** + * Get statistics for a project + */ +export async function getProjectStats(projectId: string): Promise> { + return withErrorHandling(async () => { + const [conversations, documents, links] = await Promise.all([ + db.conversations.where('projectId').equals(projectId).count(), + db.documents.where('projectId').equals(projectId).count(), + db.projectLinks.where('projectId').equals(projectId).count() + ]); + + return { + conversationCount: conversations, + documentCount: documents, + linkCount: links + }; + }); +} diff --git a/frontend/src/lib/stores/conversations.svelte.ts b/frontend/src/lib/stores/conversations.svelte.ts index 8fbf9c5..07f8520 100644 --- a/frontend/src/lib/stores/conversations.svelte.ts +++ b/frontend/src/lib/stores/conversations.svelte.ts @@ -204,6 +204,66 @@ export class ConversationsState { setSystemPrompt(id: string, systemPromptId: string | null): void { this.update(id, { systemPromptId }); } + + // ======================================================================== + // Project-related methods + // ======================================================================== + + /** + * Get conversations for a specific project + * @param projectId The project ID + */ + forProject(projectId: string): Conversation[] { + return this.items + .filter((c) => !c.isArchived && c.projectId === projectId) + .sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + } + + /** + * Get conversations without a project + */ + withoutProject(): Conversation[] { + return this.items + .filter((c) => !c.isArchived && (!c.projectId || c.projectId === null)) + .sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + } + + /** + * Move a conversation to a project (or remove from project if null) + * @param id The conversation ID + * @param projectId The project ID (or null to remove from project) + */ + moveToProject(id: string, projectId: string | null): void { + this.update(id, { projectId }); + } + + /** + * Update a conversation's summary + * @param id The conversation ID + * @param summary The summary text + */ + updateSummary(id: string, summary: string): void { + this.update(id, { summary, summaryUpdatedAt: new Date() }); + } + + /** + * Get all project IDs that have conversations + */ + getProjectIdsWithConversations(): string[] { + const projectIds = new Set(); + for (const c of this.items) { + if (!c.isArchived && c.projectId) { + projectIds.add(c.projectId); + } + } + return Array.from(projectIds); + } } /** Singleton conversations state instance */ diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts index d96abdb..d331a75 100644 --- a/frontend/src/lib/stores/index.ts +++ b/frontend/src/lib/stores/index.ts @@ -12,6 +12,7 @@ export { promptsState } from './prompts.svelte.js'; 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'; // Re-export types for convenience export type { GroupedConversations } from './conversations.svelte.js'; diff --git a/frontend/src/lib/stores/projects.svelte.ts b/frontend/src/lib/stores/projects.svelte.ts new file mode 100644 index 0000000..2ea88c1 --- /dev/null +++ b/frontend/src/lib/stores/projects.svelte.ts @@ -0,0 +1,148 @@ +/** + * Projects state management using Svelte 5 runes + * Handles project list, selection, and CRUD operations + */ + +import type { Project, ProjectLink } from '$lib/storage/projects.js'; +import * as projectStorage from '$lib/storage/projects.js'; + +// Re-export types for convenience +export type { Project, ProjectLink }; + +/** Projects state class with reactive properties */ +export class ProjectsState { + // Core state + projects = $state([]); + activeProjectId = $state(null); + isLoading = $state(false); + error = $state(null); + + // Derived: Active project + activeProject = $derived.by(() => { + if (!this.activeProjectId) return null; + return this.projects.find((p) => p.id === this.activeProjectId) ?? null; + }); + + // Derived: Projects sorted by name + sortedProjects = $derived.by(() => { + return [...this.projects].sort((a, b) => a.name.localeCompare(b.name)); + }); + + // Derived: Collapsed project IDs for quick lookup + collapsedIds = $derived.by(() => { + return new Set(this.projects.filter((p) => p.isCollapsed).map((p) => p.id)); + }); + + /** + * Load all projects from storage + */ + async load(): Promise { + this.isLoading = true; + this.error = null; + + try { + const result = await projectStorage.getAllProjects(); + if (result.success) { + this.projects = result.data; + } else { + this.error = result.error; + console.error('[ProjectsState] Failed to load projects:', result.error); + } + } finally { + this.isLoading = false; + } + } + + /** + * Create a new project + */ + async add(data: projectStorage.CreateProjectData): Promise { + this.error = null; + + const result = await projectStorage.createProject(data); + if (result.success) { + this.projects = [...this.projects, result.data]; + return result.data; + } else { + this.error = result.error; + console.error('[ProjectsState] Failed to create project:', result.error); + return null; + } + } + + /** + * Update an existing project + */ + async update(id: string, updates: projectStorage.UpdateProjectData): Promise { + this.error = null; + + const result = await projectStorage.updateProject(id, updates); + if (result.success) { + this.projects = this.projects.map((p) => + p.id === id ? result.data : p + ); + return true; + } else { + this.error = result.error; + console.error('[ProjectsState] Failed to update project:', result.error); + return false; + } + } + + /** + * Delete a project + */ + async remove(id: string): Promise { + this.error = null; + + const result = await projectStorage.deleteProject(id); + if (result.success) { + this.projects = this.projects.filter((p) => p.id !== id); + // Clear active project if it was deleted + if (this.activeProjectId === id) { + this.activeProjectId = null; + } + return true; + } else { + this.error = result.error; + console.error('[ProjectsState] Failed to delete project:', result.error); + return false; + } + } + + /** + * Toggle project collapse state + */ + async toggleCollapse(id: string): Promise { + const result = await projectStorage.toggleProjectCollapse(id); + if (result.success) { + this.projects = this.projects.map((p) => + p.id === id ? { ...p, isCollapsed: result.data } : p + ); + } + } + + /** + * Set the active project (for filtering) + */ + setActive(id: string | null): void { + this.activeProjectId = id; + } + + /** + * Find a project by ID + */ + find(id: string): Project | undefined { + return this.projects.find((p) => p.id === id); + } + + /** + * Check if a project is collapsed + */ + isCollapsed(id: string): boolean { + return this.collapsedIds.has(id); + } +} + +/** Singleton projects state instance */ +export const projectsState = new ProjectsState(); diff --git a/frontend/src/lib/types/conversation.ts b/frontend/src/lib/types/conversation.ts index d6a1cb6..239cda1 100644 --- a/frontend/src/lib/types/conversation.ts +++ b/frontend/src/lib/types/conversation.ts @@ -16,6 +16,12 @@ export interface Conversation { messageCount: number; /** Optional system prompt ID for this conversation (null = use global default) */ systemPromptId?: string | null; + /** Optional project ID this conversation belongs to */ + projectId?: string | null; + /** Auto-generated conversation summary for cross-chat context */ + summary?: string | null; + /** Timestamp when summary was last updated */ + summaryUpdatedAt?: Date | null; } /** Full conversation including message tree and navigation state */ diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index fde4228..f2172b9 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import '../app.css'; import { onMount } from 'svelte'; import { goto } from '$app/navigation'; - import { chatState, conversationsState, modelsState, uiState, promptsState, versionState } from '$lib/stores'; + import { chatState, conversationsState, modelsState, uiState, promptsState, versionState, projectsState } from '$lib/stores'; import { getAllConversations } from '$lib/storage'; import { syncManager } from '$lib/backend'; import { keyboardShortcuts, getShortcuts } from '$lib/utils'; @@ -66,6 +66,9 @@ // Load conversations from IndexedDB loadConversations(); + // Load projects from IndexedDB + projectsState.load(); + return () => { uiState.destroy(); syncManager.destroy();