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:
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
169
backend/internal/api/version.go
Normal file
169
backend/internal/api/version.go
Normal 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)
|
||||
}
|
||||
}
|
||||
94
frontend/src/lib/components/shared/UpdateBanner.svelte
Normal file
94
frontend/src/lib/components/shared/UpdateBanner.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
157
frontend/src/lib/stores/version.svelte.ts
Normal file
157
frontend/src/lib/stores/version.svelte.ts
Normal 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();
|
||||
@@ -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)} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user