diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 00419b5..a6f2a0c 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -17,6 +17,9 @@ import (
"vessel-backend/internal/database"
)
+// Version is set at build time via -ldflags, or defaults to dev
+var Version = "0.2.0"
+
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
@@ -61,7 +64,7 @@ func main() {
}))
// Register routes
- api.SetupRoutes(r, db, *ollamaURL)
+ api.SetupRoutes(r, db, *ollamaURL, Version)
// Create server
srv := &http.Server{
diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go
index d7abe59..9a6f989 100644
--- a/backend/internal/api/routes.go
+++ b/backend/internal/api/routes.go
@@ -8,7 +8,7 @@ import (
)
// SetupRoutes configures all API routes
-func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
+func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string) {
// Initialize Ollama service with official client
ollamaService, err := NewOllamaService(ollamaURL)
if err != nil {
@@ -28,6 +28,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
c.JSON(200, gin.H{"status": "ok"})
})
+ // Version endpoint (for update notifications)
+ r.GET("/api/v1/version", VersionHandler(appVersion))
+
// API v1 routes
v1 := r.Group("/api/v1")
{
diff --git a/backend/internal/api/version.go b/backend/internal/api/version.go
new file mode 100644
index 0000000..e746ad1
--- /dev/null
+++ b/backend/internal/api/version.go
@@ -0,0 +1,169 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+// VersionInfo contains version information for the API response
+type VersionInfo struct {
+ Current string `json:"current"`
+ Latest string `json:"latest,omitempty"`
+ UpdateURL string `json:"updateUrl,omitempty"`
+ HasUpdate bool `json:"hasUpdate"`
+}
+
+// GitHubRelease represents the relevant fields from GitHub releases API
+type GitHubRelease struct {
+ TagName string `json:"tag_name"`
+ HTMLURL string `json:"html_url"`
+}
+
+// versionCache holds cached version info with TTL
+type versionCache struct {
+ mu sync.RWMutex
+ latest string
+ updateURL string
+ lastFetched time.Time
+ ttl time.Duration
+}
+
+var cache = &versionCache{
+ ttl: 1 * time.Hour,
+}
+
+// getGitHubRepo returns the GitHub repo path from env or default
+func getGitHubRepo() string {
+ if repo := os.Getenv("GITHUB_REPO"); repo != "" {
+ return repo
+ }
+ return "vikingowl/vessel"
+}
+
+// fetchLatestRelease fetches the latest release from GitHub
+func fetchLatestRelease() (string, string, error) {
+ repo := getGitHubRepo()
+ url := "https://api.github.com/repos/" + repo + "/releases/latest"
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return "", "", err
+ }
+
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("User-Agent", "Vessel-Update-Checker")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", "", err
+ }
+ defer resp.Body.Close()
+
+ // 404 means no releases yet - not an error
+ if resp.StatusCode == http.StatusNotFound {
+ return "", "", nil
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", "", nil
+ }
+
+ var release GitHubRelease
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return "", "", err
+ }
+
+ // Strip 'v' prefix if present
+ version := strings.TrimPrefix(release.TagName, "v")
+ return version, release.HTMLURL, nil
+}
+
+// getLatestVersion returns cached version or fetches fresh
+func getLatestVersion() (string, string) {
+ cache.mu.RLock()
+ if time.Since(cache.lastFetched) < cache.ttl && cache.latest != "" {
+ latest, url := cache.latest, cache.updateURL
+ cache.mu.RUnlock()
+ return latest, url
+ }
+ cache.mu.RUnlock()
+
+ // Fetch fresh
+ latest, url, err := fetchLatestRelease()
+ if err != nil {
+ return "", ""
+ }
+
+ // Update cache
+ cache.mu.Lock()
+ cache.latest = latest
+ cache.updateURL = url
+ cache.lastFetched = time.Now()
+ cache.mu.Unlock()
+
+ return latest, url
+}
+
+// compareVersions returns true if latest > current (semver comparison)
+func compareVersions(current, latest string) bool {
+ if latest == "" || current == "" {
+ return false
+ }
+
+ // Strip 'v' prefix if present
+ current = strings.TrimPrefix(current, "v")
+ latest = strings.TrimPrefix(latest, "v")
+
+ currentParts := strings.Split(current, ".")
+ latestParts := strings.Split(latest, ".")
+
+ // Compare each segment
+ maxLen := len(currentParts)
+ if len(latestParts) > maxLen {
+ maxLen = len(latestParts)
+ }
+
+ for i := 0; i < maxLen; i++ {
+ var currentNum, latestNum int
+
+ if i < len(currentParts) {
+ currentNum, _ = strconv.Atoi(strings.Split(currentParts[i], "-")[0])
+ }
+ if i < len(latestParts) {
+ latestNum, _ = strconv.Atoi(strings.Split(latestParts[i], "-")[0])
+ }
+
+ if latestNum > currentNum {
+ return true
+ }
+ if latestNum < currentNum {
+ return false
+ }
+ }
+
+ return false
+}
+
+// VersionHandler returns a handler that provides version information
+func VersionHandler(currentVersion string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ latest, updateURL := getLatestVersion()
+
+ info := VersionInfo{
+ Current: currentVersion,
+ Latest: latest,
+ UpdateURL: updateURL,
+ HasUpdate: compareVersions(currentVersion, latest),
+ }
+
+ c.JSON(http.StatusOK, info)
+ }
+}
diff --git a/frontend/src/lib/components/shared/UpdateBanner.svelte b/frontend/src/lib/components/shared/UpdateBanner.svelte
new file mode 100644
index 0000000..b48dfd5
--- /dev/null
+++ b/frontend/src/lib/components/shared/UpdateBanner.svelte
@@ -0,0 +1,94 @@
+
+
+{#if versionState.shouldShowNotification && versionState.latest}
+
+
+
+
+
+
+
+ Vessel v{versionState.latest} is available
+
+
+
+ {#if versionState.updateUrl}
+
+ View Release
+
+ {/if}
+
+
+
+
+
+{/if}
+
+
diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts
index e7d5ed2..94da9ef 100644
--- a/frontend/src/lib/stores/index.ts
+++ b/frontend/src/lib/stores/index.ts
@@ -10,6 +10,7 @@ export { ToastState, toastState } from './toast.svelte.js';
export { toolsState } from './tools.svelte.js';
export { promptsState } from './prompts.svelte.js';
export type { Prompt } from './prompts.svelte.js';
+export { VersionState, versionState } from './version.svelte.js';
// Re-export types for convenience
export type { GroupedConversations } from './conversations.svelte.js';
diff --git a/frontend/src/lib/stores/version.svelte.ts b/frontend/src/lib/stores/version.svelte.ts
new file mode 100644
index 0000000..9fdb830
--- /dev/null
+++ b/frontend/src/lib/stores/version.svelte.ts
@@ -0,0 +1,157 @@
+/**
+ * Version state management for update notifications
+ * Checks for new versions on app load and periodically
+ */
+
+/** Version info from the backend */
+interface VersionInfo {
+ current: string;
+ latest?: string;
+ updateUrl?: string;
+ hasUpdate: boolean;
+}
+
+/** localStorage keys */
+const STORAGE_KEYS = {
+ dismissedVersion: 'vessel-dismissed-version',
+ lastCheck: 'vessel-last-update-check'
+} as const;
+
+/** Check interval: 12 hours in milliseconds */
+const CHECK_INTERVAL = 12 * 60 * 60 * 1000;
+
+/** Version state class with reactive properties */
+export class VersionState {
+ /** Current app version */
+ current = $state('');
+
+ /** Latest available version (if known) */
+ latest = $state(null);
+
+ /** URL to release page */
+ updateUrl = $state(null);
+
+ /** Whether an update is available */
+ hasUpdate = $state(false);
+
+ /** Timestamp of last check */
+ lastChecked = $state(0);
+
+ /** Version that was dismissed by user */
+ dismissedVersion = $state(null);
+
+ /** Whether currently checking */
+ isChecking = $state(false);
+
+ /** Interval handle for periodic checks */
+ private intervalId: ReturnType | null = null;
+
+ /** Whether notification should be shown */
+ get shouldShowNotification(): boolean {
+ return this.hasUpdate && this.latest !== null && this.latest !== this.dismissedVersion;
+ }
+
+ /**
+ * Initialize version checking
+ * Loads dismissed version from localStorage and starts periodic checks
+ */
+ initialize(): void {
+ if (typeof window === 'undefined') return;
+
+ // Load dismissed version from localStorage
+ try {
+ this.dismissedVersion = localStorage.getItem(STORAGE_KEYS.dismissedVersion);
+ const lastCheckStr = localStorage.getItem(STORAGE_KEYS.lastCheck);
+ this.lastChecked = lastCheckStr ? parseInt(lastCheckStr, 10) : 0;
+ } catch {
+ // localStorage not available
+ }
+
+ // Check if we should check now (12 hours since last check)
+ const timeSinceLastCheck = Date.now() - this.lastChecked;
+ if (timeSinceLastCheck >= CHECK_INTERVAL || this.lastChecked === 0) {
+ this.checkForUpdates();
+ }
+
+ // Set up periodic checking
+ this.intervalId = setInterval(() => {
+ this.checkForUpdates();
+ }, CHECK_INTERVAL);
+ }
+
+ /**
+ * Check for updates from the backend
+ */
+ async checkForUpdates(): Promise {
+ if (this.isChecking) return;
+
+ this.isChecking = true;
+
+ try {
+ const response = await fetch('/api/v1/version');
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const info: VersionInfo = await response.json();
+
+ this.current = info.current;
+ this.latest = info.latest || null;
+ this.updateUrl = info.updateUrl || null;
+ this.hasUpdate = info.hasUpdate;
+ this.lastChecked = Date.now();
+
+ // Save last check time
+ try {
+ localStorage.setItem(STORAGE_KEYS.lastCheck, this.lastChecked.toString());
+ } catch {
+ // localStorage not available
+ }
+ } catch (error) {
+ // Silently fail - don't bother user with update check errors
+ console.debug('Version check failed:', error);
+ } finally {
+ this.isChecking = false;
+ }
+ }
+
+ /**
+ * Dismiss the update notification for a specific version
+ * Saves to localStorage so it persists across sessions
+ */
+ dismissUpdate(version: string): void {
+ this.dismissedVersion = version;
+
+ try {
+ localStorage.setItem(STORAGE_KEYS.dismissedVersion, version);
+ } catch {
+ // localStorage not available
+ }
+ }
+
+ /**
+ * Clear dismissed version (useful for settings/debugging)
+ */
+ clearDismissed(): void {
+ this.dismissedVersion = null;
+
+ try {
+ localStorage.removeItem(STORAGE_KEYS.dismissedVersion);
+ } catch {
+ // localStorage not available
+ }
+ }
+
+ /**
+ * Clean up interval on destroy
+ */
+ destroy(): void {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+ }
+ }
+}
+
+/** Singleton version state instance */
+export const versionState = new VersionState();
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 2ace8e0..fde4228 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -7,7 +7,7 @@
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
- import { chatState, conversationsState, modelsState, uiState, promptsState } from '$lib/stores';
+ import { chatState, conversationsState, modelsState, uiState, promptsState, versionState } from '$lib/stores';
import { getAllConversations } from '$lib/storage';
import { syncManager } from '$lib/backend';
import { keyboardShortcuts, getShortcuts } from '$lib/utils';
@@ -15,6 +15,7 @@
import TopNav from '$lib/components/layout/TopNav.svelte';
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared';
+ import UpdateBanner from '$lib/components/shared/UpdateBanner.svelte';
import type { LayoutData } from './$types';
import type { Snippet } from 'svelte';
@@ -42,6 +43,9 @@
// Initialize sync manager (backend communication)
syncManager.initialize();
+ // Initialize version checker (update notifications)
+ versionState.initialize();
+
// Initialize keyboard shortcuts
keyboardShortcuts.initialize();
registerKeyboardShortcuts();
@@ -65,6 +69,7 @@
return () => {
uiState.destroy();
syncManager.destroy();
+ versionState.destroy();
keyboardShortcuts.destroy();
};
});
@@ -172,6 +177,9 @@
+
+
+
(showShortcutsModal = false)} />