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} + +{/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)} />