6 Commits

Author SHA1 Message Date
f98dea4826 chore: bump version to 0.5.2
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 22:20:44 +01:00
792cc19abe fix: improve sidebar hover icon visibility 2026-01-07 22:20:09 +01:00
27c9038835 docs: update README with new features (Projects, RAG, Search) 2026-01-07 22:15:52 +01:00
62c45492fa fix: persist conversation pin/archive state to IndexedDB
- Fix pin icons in ConversationItem to use bookmark style matching TopNav
- Make pin() and archive() methods async with IndexedDB persistence
- Use optimistic updates with rollback on failure
- Queue changes for backend sync via markForSync()
2026-01-07 22:04:01 +01:00
196d28ca25 chore: bump version to 0.5.1
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-07 20:51:56 +01:00
f3ba4c8876 fix: project deletion and replace confirm() with ConfirmDialog
Bug fixes:
- Fix project delete failing by adding db.chunks to transaction

UX improvements:
- Replace browser confirm() dialogs with styled ConfirmDialog component
- Affected: ProjectModal, ToolsTab, KnowledgeTab, PromptsTab, project page
2026-01-07 20:51:33 +01:00
12 changed files with 181 additions and 65 deletions

View File

@@ -41,20 +41,35 @@ If you want a **small, focused UI for local Ollama usage** → Vessel is built f
## Features
### Chat
- Real-time streaming responses
- Message editing with branch navigation
- Real-time streaming responses with token metrics
- **Message branching** — edit any message to create alternative conversation paths
- Markdown rendering with syntax highlighting
- **Thinking mode** — native support for reasoning models (DeepSeek-R1, etc.)
- Dark/Light themes
### Projects & Organization
- **Projects** — group related conversations together
- Pin and archive conversations
- Smart title generation from conversation content
- **Global search** — semantic, title, and content search across all chats
### Knowledge Base (RAG)
- Upload documents (text, markdown, PDF) to build a knowledge base
- **Semantic search** using embeddings for context-aware retrieval
- Project-specific or global knowledge bases
- Automatic context injection into conversations
### Tools
- **5 built-in tools**: web search, URL fetching, calculator, location, time
- **Custom tools**: Create your own in JavaScript, Python, or HTTP
- Agentic tool calling with chain-of-thought reasoning
- Test tools before saving with the built-in testing panel
### Models
- Browse and pull models from ollama.com
- Create custom models with embedded system prompts
- Track model updates
- **Per-model parameters** — customize temperature, context size, top_k/top_p
- Track model updates and capability detection (vision, tools, code)
### Prompts
- Save and organize system prompts
@@ -145,6 +160,9 @@ Full documentation is available on the **[GitHub Wiki](https://github.com/Viking
| Guide | Description |
|-------|-------------|
| [Getting Started](https://github.com/VikingOwl91/vessel/wiki/Getting-Started) | Installation and configuration |
| [Projects](https://github.com/VikingOwl91/vessel/wiki/Projects) | Organize conversations into projects |
| [Knowledge Base](https://github.com/VikingOwl91/vessel/wiki/Knowledge-Base) | RAG with document upload and semantic search |
| [Search](https://github.com/VikingOwl91/vessel/wiki/Search) | Semantic and content search across chats |
| [Custom Tools](https://github.com/VikingOwl91/vessel/wiki/Custom-Tools) | Create JavaScript, Python, or HTTP tools |
| [System Prompts](https://github.com/VikingOwl91/vessel/wiki/System-Prompts) | Manage prompts with model defaults |
| [Custom Models](https://github.com/VikingOwl91/vessel/wiki/Custom-Models) | Create models with embedded prompts |
@@ -164,6 +182,11 @@ Vessel prioritizes **usability and simplicity** over feature breadth.
- [x] Custom tools (JavaScript, Python, HTTP)
- [x] System prompt library with model-specific defaults
- [x] Custom model creation with embedded prompts
- [x] Projects for conversation organization
- [x] Knowledge base with RAG (semantic retrieval)
- [x] Global search (semantic, title, content)
- [x] Thinking mode for reasoning models
- [x] Message branching and conversation trees
**Planned:**
- [ ] Keyboard-first workflows

View File

@@ -18,7 +18,7 @@ import (
)
// Version is set at build time via -ldflags, or defaults to dev
var Version = "0.5.0"
var Version = "0.5.2"
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"type": "module",
"scripts": {

View File

@@ -98,14 +98,18 @@
<!-- Chat icon -->
<div class="mt-0.5 shrink-0">
{#if conversation.isPinned}
<!-- Pin icon for pinned conversations -->
<!-- Bookmark icon for pinned conversations -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-emerald-500"
viewBox="0 0 20 20"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
<path
fill-rule="evenodd"
d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z"
clip-rule="evenodd"
/>
</svg>
{:else}
<!-- Regular chat bubble -->
@@ -147,49 +151,36 @@
</div>
<!-- Action buttons (always visible on mobile, hover on desktop) -->
<div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 transition-opacity {uiState.isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}">
<div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-theme-secondary/90 px-1 py-0.5 shadow-sm transition-opacity {uiState.isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}">
<!-- Pin/Unpin button -->
<button
type="button"
onclick={handlePin}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
class="rounded p-1 transition-colors hover:bg-theme-tertiary {conversation.isPinned ? 'text-emerald-500 hover:text-emerald-400' : 'text-theme-secondary hover:text-theme-primary'}"
aria-label={conversation.isPinned ? 'Unpin conversation' : 'Pin conversation'}
title={conversation.isPinned ? 'Unpin' : 'Pin'}
>
{#if conversation.isPinned}
<!-- Unpin icon (filled) -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M8.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" />
</svg>
{:else}
<!-- Pin icon (outline) -->
<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="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z"
/>
</svg>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill={conversation.isPinned ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
</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"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Move to project"
title="Move to project"
>
@@ -213,7 +204,7 @@
<button
type="button"
onclick={handleExport}
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
aria-label="Export conversation"
title="Export"
>
@@ -237,7 +228,7 @@
<button
type="button"
onclick={handleDelete}
class="rounded p-1 text-theme-muted transition-colors hover:bg-red-900/50 hover:text-red-400"
class="rounded p-1 text-theme-secondary transition-colors hover:bg-red-900/50 hover:text-red-400"
aria-label="Delete conversation"
title="Delete"
>

View File

@@ -98,7 +98,7 @@
<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"
class="shrink-0 rounded p-1 text-theme-secondary opacity-0 transition-opacity hover:bg-theme-tertiary hover:text-theme-primary group-hover:opacity-100"
aria-label="Project settings"
title="Settings"
>

View File

@@ -5,6 +5,7 @@
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';
import { ConfirmDialog } from '$lib/components/shared';
interface Props {
isOpen: boolean;
@@ -26,6 +27,7 @@
let newLinkDescription = $state('');
let isLoading = $state(false);
let activeTab = $state<'settings' | 'instructions' | 'links'>('settings');
let showDeleteConfirm = $state(false);
// Predefined colors for quick selection
const presetColors = [
@@ -121,13 +123,14 @@
}
}
async function handleDelete() {
function handleDeleteClick() {
if (!projectId) return;
showDeleteConfirm = true;
}
if (!confirm('Delete this project? Conversations will be unlinked but not deleted.')) {
return;
}
async function handleDeleteConfirm() {
if (!projectId) return;
showDeleteConfirm = false;
isLoading = true;
try {
@@ -429,7 +432,7 @@
{#if projectId}
<button
type="button"
onclick={handleDelete}
onclick={handleDeleteClick}
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"
>
@@ -458,3 +461,13 @@
</div>
</div>
{/if}
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Project"
message="Delete this project? Conversations will be unlinked but not deleted."
confirmText="Delete"
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => (showDeleteConfirm = false)}
/>

View File

@@ -14,6 +14,7 @@
} from '$lib/memory';
import type { StoredDocument } from '$lib/storage/db';
import { toastState, modelsState } from '$lib/stores';
import { ConfirmDialog } from '$lib/components/shared';
let documents = $state<StoredDocument[]>([]);
let stats = $state({ documentCount: 0, chunkCount: 0, totalTokens: 0 });
@@ -22,6 +23,7 @@
let uploadProgress = $state({ current: 0, total: 0 });
let selectedModel = $state(DEFAULT_EMBEDDING_MODEL);
let dragOver = $state(false);
let deleteConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
let fileInput: HTMLInputElement;
@@ -90,10 +92,14 @@
uploadProgress = { current: 0, total: 0 };
}
async function handleDelete(doc: StoredDocument) {
if (!confirm(`Delete "${doc.name}"? This cannot be undone.`)) {
return;
}
function handleDeleteClick(doc: StoredDocument) {
deleteConfirm = { show: true, doc };
}
async function confirmDelete() {
if (!deleteConfirm.doc) return;
const doc = deleteConfirm.doc;
deleteConfirm = { show: false, doc: null };
try {
await deleteDocument(doc.id);
@@ -232,7 +238,7 @@
<button
type="button"
onclick={() => handleDelete(doc)}
onclick={() => handleDeleteClick(doc)}
class="rounded p-2 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
aria-label="Delete document"
>
@@ -273,3 +279,13 @@
{/if}
</section>
</div>
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Document"
message={`Delete "${deleteConfirm.doc?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => (deleteConfirm = { show: false, doc: null })}
/>

View File

@@ -10,9 +10,11 @@
type PromptTemplate,
type PromptCategory
} from '$lib/prompts/templates';
import { ConfirmDialog } from '$lib/components/shared';
type Tab = 'my-prompts' | 'browse-templates';
let activeTab = $state<Tab>('my-prompts');
let deleteConfirm = $state<{ show: boolean; prompt: Prompt | null }>({ show: false, prompt: null });
let showEditor = $state(false);
let editingPrompt = $state<Prompt | null>(null);
@@ -106,10 +108,14 @@
}
}
async function handleDelete(prompt: Prompt): Promise<void> {
if (confirm(`Delete "${prompt.name}"? This cannot be undone.`)) {
await promptsState.remove(prompt.id);
}
function handleDeleteClick(prompt: Prompt): void {
deleteConfirm = { show: true, prompt };
}
async function confirmDelete(): Promise<void> {
if (!deleteConfirm.prompt) return;
await promptsState.remove(deleteConfirm.prompt.id);
deleteConfirm = { show: false, prompt: null };
}
async function handleSetDefault(prompt: Prompt): Promise<void> {
@@ -286,7 +292,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button" onclick={() => handleDelete(prompt)} class="rounded p-1.5 text-theme-muted hover:bg-red-900/30 hover:text-red-400" title="Delete">
<button type="button" onclick={() => handleDeleteClick(prompt)} class="rounded p-1.5 text-theme-muted hover:bg-red-900/30 hover:text-red-400" title="Delete">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
@@ -444,3 +450,13 @@
</div>
</div>
{/if}
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Prompt"
message={`Delete "${deleteConfirm.prompt?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => (deleteConfirm = { show: false, prompt: null })}
/>

View File

@@ -5,11 +5,13 @@
import { toolsState } from '$lib/stores';
import type { ToolDefinition, CustomTool } from '$lib/tools';
import { ToolEditor } from '$lib/components/tools';
import { ConfirmDialog } from '$lib/components/shared';
let showEditor = $state(false);
let editingTool = $state<CustomTool | null>(null);
let searchQuery = $state('');
let expandedDescriptions = $state<Set<string>>(new Set());
let deleteConfirm = $state<{ show: boolean; tool: CustomTool | null }>({ show: false, tool: null });
function openCreateEditor(): void {
editingTool = null;
@@ -32,9 +34,14 @@
}
function handleDeleteTool(tool: CustomTool): void {
if (confirm(`Delete "${tool.name}"? This cannot be undone.`)) {
toolsState.removeCustomTool(tool.id);
deleteConfirm = { show: true, tool };
}
function confirmDeleteTool(): void {
if (deleteConfirm.tool) {
toolsState.removeCustomTool(deleteConfirm.tool.id);
}
deleteConfirm = { show: false, tool: null };
}
const allTools = $derived(toolsState.getAllToolsWithState());
@@ -509,3 +516,13 @@
onClose={() => { showEditor = false; editingTool = null; }}
onSave={handleSaveTool}
/>
<ConfirmDialog
isOpen={deleteConfirm.show}
title="Delete Tool"
message={`Delete "${deleteConfirm.tool?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDeleteTool}
onCancel={() => (deleteConfirm = { show: false, tool: null })}
/>

View File

@@ -175,7 +175,7 @@ export async function updateProject(
*/
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 () => {
await db.transaction('rw', [db.projects, db.projectLinks, db.conversations, db.documents, db.chunks, db.chatChunks], async () => {
// Unlink all conversations from this project
const conversations = await db.conversations.where('projectId').equals(id).toArray();
for (const conv of conversations) {

View File

@@ -4,6 +4,7 @@
*/
import type { Conversation } from '$lib/types/conversation.js';
import { pinConversation, archiveConversation } from '$lib/storage/conversations.js';
/** Date group labels */
type DateGroup = 'Today' | 'Yesterday' | 'Previous 7 Days' | 'Previous 30 Days' | 'Older';
@@ -161,23 +162,43 @@ export class ConversationsState {
/**
* Toggle pin status of a conversation
* Persists to IndexedDB and queues for backend sync
* @param id The conversation ID
*/
pin(id: string): void {
async pin(id: string): Promise<void> {
const conversation = this.items.find((c) => c.id === id);
if (conversation) {
// Update in-memory state immediately for responsive UI
this.update(id, { isPinned: !conversation.isPinned });
// Persist to IndexedDB and queue for sync
const result = await pinConversation(id);
if (!result.success) {
// Revert on failure
this.update(id, { isPinned: conversation.isPinned });
console.error('Failed to persist pin state:', result.error);
}
}
}
/**
* Toggle archive status of a conversation
* Persists to IndexedDB and queues for backend sync
* @param id The conversation ID
*/
archive(id: string): void {
async archive(id: string): Promise<void> {
const conversation = this.items.find((c) => c.id === id);
if (conversation) {
// Update in-memory state immediately for responsive UI
this.update(id, { isArchived: !conversation.isArchived });
// Persist to IndexedDB and queue for sync
const result = await archiveConversation(id);
if (!result.success) {
// Revert on failure
this.update(id, { isArchived: conversation.isArchived });
console.error('Failed to persist archive state:', result.error);
}
}
}

View File

@@ -20,6 +20,7 @@
import type { StoredDocument } from '$lib/storage/db';
import ProjectModal from '$lib/components/projects/ProjectModal.svelte';
import { searchProjectChatHistory, type ChatSearchResult } from '$lib/services/chat-indexer.js';
import { ConfirmDialog } from '$lib/components/shared';
// Get project ID from URL
const projectId = $derived($page.params.id);
@@ -50,6 +51,7 @@
let isSearching = $state(false);
let searchResults = $state<ChatSearchResult[]>([]);
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let deleteDocConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
// Map of conversationId -> best matching snippet from search
const searchSnippetMap = $derived.by(() => {
@@ -323,8 +325,14 @@
await loadProjectData();
}
async function handleDeleteDocument(doc: StoredDocument) {
if (!confirm(`Delete "${doc.name}"? This cannot be undone.`)) return;
function handleDeleteDocumentClick(doc: StoredDocument) {
deleteDocConfirm = { show: true, doc };
}
async function confirmDeleteDocument() {
if (!deleteDocConfirm.doc) return;
const doc = deleteDocConfirm.doc;
deleteDocConfirm = { show: false, doc: null };
try {
await deleteDocument(doc.id);
@@ -632,7 +640,7 @@
</div>
<button
type="button"
onclick={() => handleDeleteDocument(doc)}
onclick={() => handleDeleteDocumentClick(doc)}
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
>
<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">
@@ -705,3 +713,14 @@
{projectId}
onUpdate={() => loadProjectData()}
/>
<!-- Delete Document Confirm -->
<ConfirmDialog
isOpen={deleteDocConfirm.show}
title="Delete Document"
message={`Delete "${deleteDocConfirm.doc?.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={confirmDeleteDocument}
onCancel={() => (deleteDocConfirm = { show: false, doc: null })}
/>