feat: add update notification system

Backend:
- Add /api/v1/version endpoint returning current and latest version
- Fetch latest release from GitHub API with 1-hour cache
- Semver comparison to detect available updates
- Configurable via GITHUB_REPO env var (default: vikingowl/vessel)

Frontend:
- Add VersionState store with 12-hour periodic checking
- Check on app load and periodically for new versions
- Persist dismissed versions in localStorage
- Add UpdateBanner component with teal styling
- Slide-in animation from top, dismissible

The notification appears below TopNav when a new version is available
and remembers dismissals per-version across sessions.
This commit is contained in:
2026-01-02 19:29:02 +01:00
parent 448db59aac
commit 2f28b689f5
7 changed files with 438 additions and 3 deletions

View File

@@ -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{

View File

@@ -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")
{

View File

@@ -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)
}
}

View File

@@ -0,0 +1,94 @@
<script lang="ts">
/**
* UpdateBanner.svelte - Notification banner for new version availability
* Shows when a new version is available and hasn't been dismissed
*/
import { versionState } from '$lib/stores/version.svelte.js';
/** Dismiss the current update notification */
function handleDismiss() {
if (versionState.latest) {
versionState.dismissUpdate(versionState.latest);
}
}
</script>
{#if versionState.shouldShowNotification && versionState.latest}
<div
class="fixed left-0 right-0 top-12 z-50 flex items-center justify-center px-4 animate-in"
role="alert"
>
<div
class="flex items-center gap-3 rounded-lg border border-teal-500/30 bg-teal-500/10 px-4 py-2 text-teal-400 shadow-lg backdrop-blur-sm"
>
<!-- Update icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<!-- Message -->
<span class="text-sm font-medium">
Vessel v{versionState.latest} is available
</span>
<!-- View release link -->
{#if versionState.updateUrl}
<a
href={versionState.updateUrl}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-teal-300 underline underline-offset-2 transition-colors hover:text-teal-200"
>
View Release
</a>
{/if}
<!-- Dismiss button -->
<button
type="button"
onclick={handleDismiss}
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
aria-label="Dismiss update notification"
>
<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>
</div>
{/if}
<style>
@keyframes slide-in-from-top {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-in {
animation: slide-in-from-top 0.3s ease-out;
}
</style>

View File

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

View File

@@ -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<string>('');
/** Latest available version (if known) */
latest = $state<string | null>(null);
/** URL to release page */
updateUrl = $state<string | null>(null);
/** Whether an update is available */
hasUpdate = $state(false);
/** Timestamp of last check */
lastChecked = $state<number>(0);
/** Version that was dismissed by user */
dismissedVersion = $state<string | null>(null);
/** Whether currently checking */
isChecking = $state(false);
/** Interval handle for periodic checks */
private intervalId: ReturnType<typeof setInterval> | 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<void> {
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();

View File

@@ -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 @@
<!-- Toast notifications -->
<ToastContainer />
<!-- Update notification banner -->
<UpdateBanner />
<!-- Keyboard shortcuts help -->
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />