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
This commit is contained in:
2026-01-07 14:36:12 +01:00
parent 080deb756b
commit 5e6994f415
18 changed files with 2211 additions and 24 deletions

View File

@@ -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<string | null> {
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) {

View File

@@ -1,13 +1,14 @@
<script lang="ts">
/**
* ConversationItem.svelte - Single conversation row in the sidebar
* Shows title, model, and hover actions (pin, export, delete)
* Shows title, model, and hover actions (pin, move, export, delete)
*/
import type { Conversation } from '$lib/types/conversation.js';
import { goto } from '$app/navigation';
import { conversationsState, uiState, chatState, toastState } from '$lib/stores';
import { deleteConversation } from '$lib/storage';
import { ExportDialog } from '$lib/components/shared';
import MoveToProjectModal from '$lib/components/projects/MoveToProjectModal.svelte';
interface Props {
conversation: Conversation;
@@ -19,6 +20,9 @@
// Export dialog state
let showExportDialog = $state(false);
// Move to project dialog state
let showMoveDialog = $state(false);
/** Format relative time for display */
function formatRelativeTime(date: Date): string {
const now = new Date();
@@ -48,6 +52,13 @@
showExportDialog = true;
}
/** Handle move to project */
function handleMove(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
showMoveDialog = true;
}
/** Handle delete */
async function handleDelete(e: MouseEvent) {
e.preventDefault();
@@ -174,6 +185,30 @@
{/if}
</button>
<!-- Move to project button -->
<button
type="button"
onclick={handleMove}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Move to project"
title="Move to project"
>
<svg
xmlns="http://www.w3.org/2000/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="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
/>
</svg>
</button>
<!-- Export button -->
<button
type="button"
@@ -230,3 +265,10 @@
isOpen={showExportDialog}
onClose={() => (showExportDialog = false)}
/>
<!-- Move to Project Modal -->
<MoveToProjectModal
conversationId={conversation.id}
isOpen={showMoveDialog}
onClose={() => (showMoveDialog = false)}
/>

View File

@@ -1,17 +1,34 @@
<script lang="ts">
/**
* ConversationList.svelte - Chat history list grouped by date
* Uses local conversationsState for immediate updates (offline-first)
* ConversationList.svelte - Chat history list with projects and date groups
* Shows projects as folders at the top, then ungrouped conversations by date
*/
import { conversationsState, chatState } from '$lib/stores';
import { conversationsState, chatState, projectsState } from '$lib/stores';
import ConversationItem from './ConversationItem.svelte';
import ProjectFolder from './ProjectFolder.svelte';
interface Props {
onEditProject?: (projectId: string) => void;
}
let { onEditProject }: Props = $props();
// State for showing archived conversations
let showArchived = $state(false);
// Derived: Conversations without a project, grouped by date
const ungroupedConversations = $derived.by(() => {
return conversationsState.withoutProject();
});
// Derived: Check if there are any project folders or ungrouped conversations
const hasAnyContent = $derived.by(() => {
return projectsState.projects.length > 0 || ungroupedConversations.length > 0;
});
</script>
<div class="flex flex-col px-2 py-1">
{#if conversationsState.grouped.length === 0}
{#if !hasAnyContent && conversationsState.grouped.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center px-4 py-8 text-center">
<svg
@@ -43,24 +60,45 @@
{/if}
</div>
{:else}
<!-- Grouped conversations -->
{#each conversationsState.grouped as { group, conversations } (group)}
<div class="mb-2">
<!-- Group header -->
<!-- Projects section -->
{#if projectsState.sortedProjects.length > 0}
<div class="mb-3">
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
{group}
Projects
</h3>
<!-- Conversations in this group -->
<div class="flex flex-col gap-0.5">
{#each conversations as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
{#each projectsState.sortedProjects as project (project.id)}
<ProjectFolder
{project}
conversations={conversationsState.forProject(project.id)}
{onEditProject}
/>
{/each}
</div>
</div>
{/if}
<!-- Ungrouped conversations (by date) -->
{#each conversationsState.grouped as { group, conversations } (group)}
{@const ungroupedInGroup = conversations.filter(c => !c.projectId)}
{#if ungroupedInGroup.length > 0}
<div class="mb-2">
<!-- Group header -->
<h3 class="sticky top-0 z-10 bg-theme-primary px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-theme-muted">
{group}
</h3>
<!-- Conversations in this group (without project) -->
<div class="flex flex-col gap-0.5">
{#each ungroupedInGroup as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
/>
{/each}
</div>
</div>
{/if}
{/each}
<!-- Archived section -->

View File

@@ -0,0 +1,124 @@
<script lang="ts">
/**
* ProjectFolder.svelte - Collapsible folder for project conversations
* Shows project name, color indicator, and nested conversations
*/
import type { Project } from '$lib/stores/projects.svelte.js';
import type { Conversation } from '$lib/types/conversation.js';
import { projectsState, chatState } from '$lib/stores';
import ConversationItem from './ConversationItem.svelte';
interface Props {
project: Project;
conversations: Conversation[];
onEditProject?: (projectId: string) => void;
}
let { project, conversations, onEditProject }: Props = $props();
// Track if this project is expanded
const isExpanded = $derived(!projectsState.collapsedIds.has(project.id));
/** Toggle folder collapse state */
async function handleToggle(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
await projectsState.toggleCollapse(project.id);
}
/** Handle project settings click */
function handleSettings(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onEditProject?.(project.id);
}
</script>
<div class="mb-1">
<!-- Project header - use div with role="button" to avoid nested buttons -->
<div
role="button"
tabindex="0"
onclick={handleToggle}
onkeydown={(e) => 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"
>
<!-- Collapse indicator -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 shrink-0 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
<!-- Folder icon with project color -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 shrink-0"
viewBox="0 0 20 20"
fill={project.color || '#10b981'}
>
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
<!-- Project name and count -->
<span class="flex-1 truncate text-sm font-medium text-theme-secondary">
{project.name}
</span>
<span class="shrink-0 text-xs text-theme-muted">
{conversations.length}
</span>
<!-- Settings button (hidden until hover) -->
<button
type="button"
onclick={handleSettings}
class="shrink-0 rounded p-0.5 text-theme-muted opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-theme-primary group-hover:opacity-100"
aria-label="Project settings"
title="Settings"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<!-- Conversations in this project -->
{#if isExpanded && conversations.length > 0}
<div class="ml-3 flex flex-col gap-0.5 border-l border-theme/30 pl-2">
{#each conversations as conversation (conversation.id)}
<ConversationItem
{conversation}
isSelected={chatState.conversationId === conversation.id}
/>
{/each}
</div>
{/if}
<!-- Empty state for expanded folder with no conversations -->
{#if isExpanded && conversations.length === 0}
<div class="ml-3 border-l border-theme/30 pl-2">
<p class="px-3 py-2 text-xs text-theme-muted italic">
No conversations yet
</p>
</div>
{/if}
</div>

View File

@@ -1,16 +1,36 @@
<script lang="ts">
/**
* Sidenav.svelte - Collapsible sidebar for the Ollama chat UI
* Contains navigation header, search, and conversation list
* Contains navigation header, search, projects, and conversation list
*/
import { page } from '$app/stores';
import { uiState } from '$lib/stores';
import { uiState, projectsState } from '$lib/stores';
import SidenavHeader from './SidenavHeader.svelte';
import SidenavSearch from './SidenavSearch.svelte';
import ConversationList from './ConversationList.svelte';
import ProjectModal from '$lib/components/projects/ProjectModal.svelte';
// Check if a path is active
const isActive = (path: string) => $page.url.pathname === path;
// Project modal state
let showProjectModal = $state(false);
let editingProjectId = $state<string | null>(null);
function handleCreateProject() {
editingProjectId = null;
showProjectModal = true;
}
function handleEditProject(projectId: string) {
editingProjectId = projectId;
showProjectModal = true;
}
function handleCloseProjectModal() {
showProjectModal = false;
editingProjectId = null;
}
</script>
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
@@ -38,9 +58,34 @@
<!-- Search bar -->
<SidenavSearch />
<!-- Create Project button -->
<div class="px-3 pb-2">
<button
type="button"
onclick={handleCreateProject}
class="flex w-full items-center gap-2 rounded-lg border border-dashed border-theme px-3 py-2 text-sm text-theme-muted transition-colors hover:border-emerald-500/50 hover:bg-theme-secondary/50 hover:text-emerald-500"
>
<svg
xmlns="http://www.w3.org/2000/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 4.5v15m7.5-7.5h-15"
/>
</svg>
<span>New Project</span>
</button>
</div>
<!-- Conversation list (scrollable) -->
<div class="flex-1 overflow-y-auto overflow-x-hidden">
<ConversationList />
<ConversationList onEditProject={handleEditProject} />
</div>
<!-- Footer / Navigation links -->
@@ -158,3 +203,10 @@
</div>
</div>
</aside>
<!-- Project Modal -->
<ProjectModal
isOpen={showProjectModal}
onClose={handleCloseProjectModal}
projectId={editingProjectId}
/>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
/**
* MoveToProjectModal - Move a conversation to a different project
*/
import { projectsState, conversationsState, toastState } from '$lib/stores';
import { moveConversationToProject } from '$lib/storage/conversations.js';
interface Props {
isOpen: boolean;
onClose: () => void;
conversationId: string;
}
let { isOpen, onClose, conversationId }: Props = $props();
let isLoading = $state(false);
// Get current conversation's project
const currentConversation = $derived.by(() => {
return conversationsState.find(conversationId);
});
const currentProjectId = $derived(currentConversation?.projectId || null);
async function handleSelect(projectId: string | null) {
if (projectId === currentProjectId) {
onClose();
return;
}
isLoading = true;
try {
const result = await moveConversationToProject(conversationId, projectId);
if (result.success) {
// Update local state
conversationsState.moveToProject(conversationId, projectId);
const projectName = projectId
? projectsState.projects.find(p => p.id === projectId)?.name || 'project'
: 'No Project';
toastState.success(`Moved to ${projectName}`);
onClose();
} else {
toastState.error('Failed to move conversation');
}
} catch {
toastState.error('Failed to move conversation');
} finally {
isLoading = false;
}
}
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="move-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-sm 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">
<h2 id="move-dialog-title" class="text-lg font-semibold text-theme-primary">
Move to Project
</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/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 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="max-h-[50vh] overflow-y-auto px-2 py-3">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent"></div>
</div>
{:else}
<!-- No Project option -->
<button
type="button"
onclick={() => handleSelect(null)}
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === null ? 'bg-theme-secondary' : ''}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-theme-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
/>
</svg>
<span class="text-sm text-theme-secondary">No Project</span>
{#if currentProjectId === null}
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
<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>
<!-- Project options -->
{#if projectsState.sortedProjects.length > 0}
<div class="my-2 border-t border-theme"></div>
{#each projectsState.sortedProjects as project (project.id)}
<button
type="button"
onclick={() => handleSelect(project.id)}
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-theme-secondary {currentProjectId === project.id ? 'bg-theme-secondary' : ''}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
viewBox="0 0 20 20"
fill={project.color || '#10b981'}
>
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
<span class="truncate text-sm text-theme-secondary">{project.name}</span>
{#if currentProjectId === project.id}
<svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-5 w-5 shrink-0 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
<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>
{/each}
{/if}
<!-- Empty state -->
{#if projectsState.sortedProjects.length === 0}
<p class="px-4 py-6 text-center text-sm text-theme-muted">
No projects yet. Create one from the sidebar.
</p>
{/if}
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,457 @@
<script lang="ts">
/**
* ProjectModal - Create/Edit project with tabs for settings, instructions, and links
*/
import { projectsState, toastState } from '$lib/stores';
import type { Project } from '$lib/stores/projects.svelte.js';
import { addProjectLink, deleteProjectLink, getProjectLinks, type ProjectLink } from '$lib/storage/projects.js';
interface Props {
isOpen: boolean;
onClose: () => void;
projectId?: string | null;
}
let { isOpen, onClose, projectId = null }: Props = $props();
// Form state
let name = $state('');
let description = $state('');
let instructions = $state('');
let color = $state('#10b981');
let links = $state<ProjectLink[]>([]);
let newLinkUrl = $state('');
let newLinkTitle = $state('');
let newLinkDescription = $state('');
let isLoading = $state(false);
let activeTab = $state<'settings' | 'instructions' | 'links'>('settings');
// Predefined colors for quick selection
const presetColors = [
'#10b981', // emerald
'#3b82f6', // blue
'#8b5cf6', // violet
'#f59e0b', // amber
'#ef4444', // red
'#ec4899', // pink
'#06b6d4', // cyan
'#84cc16', // lime
];
// Get existing project data when editing
const existingProject = $derived.by(() => {
if (!projectId) return null;
return projectsState.projects.find(p => p.id === projectId) || null;
});
// Modal title
const modalTitle = $derived(projectId ? 'Edit Project' : 'Create Project');
// Reset form when modal opens/closes or project changes
$effect(() => {
if (isOpen) {
if (existingProject) {
name = existingProject.name;
description = existingProject.description || '';
instructions = existingProject.instructions || '';
color = existingProject.color || '#10b981';
loadProjectLinks();
} else {
name = '';
description = '';
instructions = '';
color = '#10b981';
links = [];
}
activeTab = 'settings';
}
});
async function loadProjectLinks() {
if (!projectId) return;
const result = await getProjectLinks(projectId);
if (result.success) {
links = result.data;
}
}
async function handleSave() {
if (!name.trim()) {
toastState.error('Project name is required');
return;
}
isLoading = true;
try {
if (projectId) {
// Update existing project
const success = await projectsState.update(projectId, {
name: name.trim(),
description: description.trim(),
instructions: instructions.trim(),
color
});
if (success) {
toastState.success('Project updated');
onClose();
} else {
toastState.error('Failed to update project');
}
} else {
// Create new project
const project = await projectsState.add({
name: name.trim(),
description: description.trim(),
instructions: instructions.trim(),
color
});
if (project) {
toastState.success('Project created');
onClose();
} else {
toastState.error('Failed to create project');
}
}
} finally {
isLoading = false;
}
}
async function handleDelete() {
if (!projectId) return;
if (!confirm('Delete this project? Conversations will be unlinked but not deleted.')) {
return;
}
isLoading = true;
try {
const success = await projectsState.remove(projectId);
if (success) {
toastState.success('Project deleted');
onClose();
} else {
toastState.error('Failed to delete project');
}
} finally {
isLoading = false;
}
}
async function handleAddLink() {
if (!projectId || !newLinkUrl.trim()) {
toastState.error('URL is required');
return;
}
try {
const result = await addProjectLink({
projectId,
url: newLinkUrl.trim(),
title: newLinkTitle.trim() || newLinkUrl.trim(),
description: newLinkDescription.trim()
});
if (result.success) {
links = [...links, result.data];
newLinkUrl = '';
newLinkTitle = '';
newLinkDescription = '';
toastState.success('Link added');
} else {
toastState.error('Failed to add link');
}
} catch {
toastState.error('Failed to add link');
}
}
async function handleDeleteLink(linkId: string) {
try {
const result = await deleteProjectLink(linkId);
if (result.success) {
links = links.filter(l => l.id !== linkId);
toastState.success('Link removed');
} else {
toastState.error('Failed to remove link');
}
} catch {
toastState.error('Failed to remove link');
}
}
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-lg 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">
<h2 id="project-dialog-title" class="text-lg font-semibold text-theme-primary">
{modalTitle}
</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/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 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="border-b border-theme px-6">
<div class="flex gap-4">
<button
type="button"
onclick={() => (activeTab = 'settings')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'settings' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Settings
{#if activeTab === 'settings'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'instructions')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'instructions' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Instructions
{#if activeTab === 'instructions'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
{#if projectId}
<button
type="button"
onclick={() => (activeTab = 'links')}
class="relative py-3 text-sm font-medium transition-colors {activeTab === 'links' ? 'text-emerald-500' : 'text-theme-muted hover:text-theme-primary'}"
>
Links ({links.length})
{#if activeTab === 'links'}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"></div>
{/if}
</button>
{/if}
</div>
</div>
<!-- Content -->
<div class="max-h-[50vh] overflow-y-auto px-6 py-4">
{#if activeTab === 'settings'}
<!-- Settings Tab -->
<div class="space-y-4">
<!-- Name -->
<div>
<label for="project-name" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Name <span class="text-red-500">*</span>
</label>
<input
id="project-name"
type="text"
bind:value={name}
placeholder="My Project"
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
/>
</div>
<!-- Description -->
<div>
<label for="project-description" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Description
</label>
<input
id="project-description"
type="text"
bind:value={description}
placeholder="Optional description"
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
/>
</div>
<!-- Color -->
<div>
<label class="mb-1.5 block text-sm font-medium text-theme-secondary">
Color
</label>
<div class="flex items-center gap-2">
{#each presetColors as presetColor}
<button
type="button"
onclick={() => (color = presetColor)}
class="h-6 w-6 rounded-full border-2 transition-transform hover:scale-110 {color === presetColor ? 'border-white shadow-lg' : 'border-transparent'}"
style="background-color: {presetColor}"
aria-label="Select color {presetColor}"
></button>
{/each}
<input
type="color"
bind:value={color}
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent"
title="Custom color"
/>
</div>
</div>
</div>
{:else if activeTab === 'instructions'}
<!-- Instructions Tab -->
<div>
<label for="project-instructions" class="mb-1.5 block text-sm font-medium text-theme-secondary">
Project Instructions
</label>
<p class="mb-2 text-xs text-theme-muted">
These instructions are injected into the system prompt for all chats in this project.
</p>
<textarea
id="project-instructions"
bind:value={instructions}
rows="10"
placeholder="You are helping with..."
class="w-full rounded-lg border border-theme bg-theme-tertiary px-3 py-2 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
></textarea>
</div>
{:else if activeTab === 'links'}
<!-- Links Tab -->
<div class="space-y-4">
<!-- Add new link form -->
<div class="rounded-lg border border-theme bg-theme-secondary/30 p-3">
<h4 class="mb-2 text-sm font-medium text-theme-secondary">Add Reference Link</h4>
<div class="space-y-2">
<input
type="url"
bind:value={newLinkUrl}
placeholder="https://..."
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<input
type="text"
bind:value={newLinkTitle}
placeholder="Title (optional)"
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<input
type="text"
bind:value={newLinkDescription}
placeholder="Description (optional)"
class="w-full rounded border border-theme bg-theme-tertiary px-2 py-1.5 text-sm text-theme-primary placeholder-theme-muted focus:border-emerald-500/50 focus:outline-none"
/>
<button
type="button"
onclick={handleAddLink}
disabled={!newLinkUrl.trim()}
class="w-full rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
Add Link
</button>
</div>
</div>
<!-- Existing links -->
{#if links.length === 0}
<p class="py-4 text-center text-sm text-theme-muted">No links added yet</p>
{:else}
<div class="space-y-2">
{#each links as link (link.id)}
<div class="flex items-start gap-2 rounded-lg border border-theme bg-theme-secondary/30 p-2">
<div class="min-w-0 flex-1">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="block truncate text-sm font-medium text-emerald-500 hover:text-emerald-400"
>
{link.title}
</a>
{#if link.description}
<p class="truncate text-xs text-theme-muted">{link.description}</p>
{/if}
</div>
<button
type="button"
onclick={() => handleDeleteLink(link.id)}
class="shrink-0 rounded p-1 text-theme-muted hover:bg-red-900/50 hover:text-red-400"
aria-label="Remove link"
>
<svg xmlns="http://www.w3.org/2000/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 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-theme px-6 py-4">
<div>
{#if projectId}
<button
type="button"
onclick={handleDelete}
disabled={isLoading}
class="rounded-lg px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-900/30 disabled:opacity-50"
>
Delete Project
</button>
{/if}
</div>
<div class="flex gap-3">
<button
type="button"
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
>
Cancel
</button>
<button
type="button"
onclick={handleSave}
disabled={isLoading || !name.trim()}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
{isLoading ? 'Saving...' : projectId ? 'Save Changes' : 'Create Project'}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -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<number> {
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<void> {
// 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<void> {
await db.chatChunks.where('conversationId').equals(conversationId).delete();
}
/**
* Remove all indexed chunks for a project
*/
export async function removeProjectFromIndex(projectId: string): Promise<void> {
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<ChatSearchResult[]> {
// 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<boolean> {
const count = await db.chatChunks
.where('conversationId')
.equals(conversationId)
.count();
return count > 0;
}

View File

@@ -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<string> {
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<boolean> {
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<boolean> {
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:`;
}

View File

@@ -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<ProjectContext> {
// 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<ChatHistoryResult[]> {
// 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
);
}

View File

@@ -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<StorageResult<Con
* Create a new conversation
*/
export async function createConversation(
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount'>
data: Omit<Conversation, 'id' | 'createdAt' | 'updatedAt' | 'messageCount' | 'summary' | 'summaryUpdatedAt'>
): Promise<StorageResult<Conversation>> {
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<StorageResult<
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)
}));
});
}

View File

@@ -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<StoredPrompt>;
modelSystemPrompts!: Table<StoredModelSystemPrompt>;
modelPromptMappings!: Table<StoredModelPromptMapping>;
// Project-related tables (v6)
projects!: Table<StoredProject>;
projectLinks!: Table<StoredProjectLink>;
chatChunks!: Table<StoredChatChunk>;
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'
});
}
}

View File

@@ -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<StorageResult<Project[]>> {
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<StorageResult<Project | null>> {
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<StorageResult<Project>> {
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<StorageResult<Project>> {
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<StorageResult<void>> {
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<StorageResult<boolean>> {
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<StorageResult<ProjectLink[]>> {
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<StorageResult<ProjectLink>> {
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<Pick<ProjectLink, 'url' | 'title' | 'description'>>
): Promise<StorageResult<ProjectLink>> {
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<StorageResult<void>> {
return withErrorHandling(async () => {
await db.projectLinks.delete(id);
});
}
// ============================================================================
// Project Statistics
// ============================================================================
/**
* Get statistics for a project
*/
export async function getProjectStats(projectId: string): Promise<StorageResult<{
conversationCount: number;
documentCount: number;
linkCount: number;
}>> {
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
};
});
}

View File

@@ -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<string>();
for (const c of this.items) {
if (!c.isArchived && c.projectId) {
projectIds.add(c.projectId);
}
}
return Array.from(projectIds);
}
}
/** Singleton conversations state instance */

View File

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

View File

@@ -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<Project[]>([]);
activeProjectId = $state<string | null>(null);
isLoading = $state(false);
error = $state<string | null>(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<void> {
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<Project | null> {
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<boolean> {
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<boolean> {
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<void> {
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();

View File

@@ -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 */

View File

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