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:
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
124
frontend/src/lib/components/layout/ProjectFolder.svelte
Normal file
124
frontend/src/lib/components/layout/ProjectFolder.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
176
frontend/src/lib/components/projects/MoveToProjectModal.svelte
Normal file
176
frontend/src/lib/components/projects/MoveToProjectModal.svelte
Normal 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}
|
||||
457
frontend/src/lib/components/projects/ProjectModal.svelte
Normal file
457
frontend/src/lib/components/projects/ProjectModal.svelte
Normal 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}
|
||||
191
frontend/src/lib/services/chat-indexer.ts
Normal file
191
frontend/src/lib/services/chat-indexer.ts
Normal 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;
|
||||
}
|
||||
157
frontend/src/lib/services/conversation-summary.ts
Normal file
157
frontend/src/lib/services/conversation-summary.ts
Normal 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:`;
|
||||
}
|
||||
174
frontend/src/lib/services/project-context.ts
Normal file
174
frontend/src/lib/services/project-context.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
310
frontend/src/lib/storage/projects.ts
Normal file
310
frontend/src/lib/storage/projects.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
148
frontend/src/lib/stores/projects.svelte.ts
Normal file
148
frontend/src/lib/stores/projects.svelte.ts
Normal 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();
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user