7 Commits

Author SHA1 Message Date
802db229a6 feat: add model filters and last updated display
Some checks failed
Create Release / release (push) Has been cancelled
- Add size filter (≤3B, 4-13B, 14-70B, >70B) based on model tags
- Add model family filter dropdown with dynamic family list
- Display last updated date on model cards (scraped from ollama.com)
- Add /api/v1/models/remote/families endpoint
- Convert relative time strings ("2 weeks ago") to timestamps during sync
2026-01-02 21:54:50 +01:00
14b566ce2a feat: add DEV_PORT env var for running dev alongside production 2026-01-02 21:17:32 +01:00
7ef29aba37 fix: coerce numeric tool args to handle string values from Ollama
Ollama models sometimes output numbers as strings in tool call arguments.
Go backend strictly rejects string→int coercion, causing errors like:
"cannot unmarshal string into Go struct field URLFetchRequest.maxLength"

- fetch_url: coerce maxLength, timeout
- web_search: coerce maxResults, timeout
- calculate: coerce precision
2026-01-02 21:08:52 +01:00
3c8d811cdc chore: bump version to 0.4.3
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 21:05:03 +01:00
5cab71dd78 fix: sync context progress bar with custom context length setting
- Add customMaxTokens override to ContextManager
- maxTokens is now derived from custom override or model default
- ChatWindow syncs settings.num_ctx to context manager
- Progress bar now shows custom context length when enabled
2026-01-02 21:04:47 +01:00
41bee19f6b chore: bump version to 0.4.2
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 20:54:55 +01:00
f4febf8973 fix: initialize custom parameters from model defaults
- Fetch actual model defaults from Ollama's /api/show endpoint
- Parse YAML-like parameters field (e.g., "temperature 0.7")
- Cache model defaults to avoid repeated API calls
- Initialize sliders with model's actual values when enabling custom params
- Show asterisk indicator when parameter differs from model default
- Reset button now restores to model defaults, not hardcoded values
2026-01-02 20:52:47 +01:00
16 changed files with 752 additions and 59 deletions

5
.gitignore vendored
View File

@@ -36,3 +36,8 @@ docker-compose.override.yml
# Claude Code project instructions (local only)
CLAUDE.md
# Dev artifacts
dev.env
backend/vessel-backend
data/

View File

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

View File

@@ -70,6 +70,7 @@ type ScrapedModel struct {
PullCount int64
Tags []string
Capabilities []string
UpdatedAt string // Relative time like "2 weeks ago" converted to RFC3339
}
// scrapeOllamaLibrary fetches the model list from ollama.com/library
@@ -168,6 +169,14 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) {
capabilities = append(capabilities, "cloud")
}
// Extract updated time from <span x-test-updated>2 weeks ago</span>
updatedPattern := regexp.MustCompile(`<span[^>]*x-test-updated[^>]*>([^<]+)</span>`)
updatedAt := ""
if um := updatedPattern.FindStringSubmatch(cardContent); len(um) > 1 {
relativeTime := strings.TrimSpace(um[1])
updatedAt = parseRelativeTime(relativeTime)
}
models[slug] = &ScrapedModel{
Slug: slug,
Name: slug,
@@ -176,6 +185,7 @@ func parseLibraryHTML(html string) ([]ScrapedModel, error) {
PullCount: pullCount,
Tags: tags,
Capabilities: capabilities,
UpdatedAt: updatedAt,
}
}
@@ -211,6 +221,52 @@ func decodeHTMLEntities(s string) string {
return s
}
// parseRelativeTime converts relative time strings like "2 weeks ago" to RFC3339 timestamps
func parseRelativeTime(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
now := time.Now()
// Parse patterns like "2 weeks ago", "1 month ago", "3 days ago"
pattern := regexp.MustCompile(`(\d+)\s*(second|minute|hour|day|week|month|year)s?\s*ago`)
matches := pattern.FindStringSubmatch(s)
if len(matches) < 3 {
return ""
}
num, err := strconv.Atoi(matches[1])
if err != nil {
return ""
}
unit := matches[2]
var duration time.Duration
switch unit {
case "second":
duration = time.Duration(num) * time.Second
case "minute":
duration = time.Duration(num) * time.Minute
case "hour":
duration = time.Duration(num) * time.Hour
case "day":
duration = time.Duration(num) * 24 * time.Hour
case "week":
duration = time.Duration(num) * 7 * 24 * time.Hour
case "month":
duration = time.Duration(num) * 30 * 24 * time.Hour
case "year":
duration = time.Duration(num) * 365 * 24 * time.Hour
default:
return ""
}
return now.Add(-duration).Format(time.RFC3339)
}
// extractDescription tries to find the description for a model
func extractDescription(html, slug string) string {
// Look for text after the model link that looks like a description
@@ -417,15 +473,16 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool
modelType := inferModelType(model.Slug)
_, err := s.db.ExecContext(ctx, `
INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, scraped_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO remote_models (slug, name, description, model_type, url, pull_count, tags, capabilities, ollama_updated_at, scraped_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
description = COALESCE(NULLIF(excluded.description, ''), remote_models.description),
model_type = excluded.model_type,
pull_count = excluded.pull_count,
capabilities = excluded.capabilities,
ollama_updated_at = COALESCE(excluded.ollama_updated_at, remote_models.ollama_updated_at),
scraped_at = excluded.scraped_at
`, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), now)
`, model.Slug, model.Name, model.Description, modelType, model.URL, model.PullCount, string(tagsJSON), string(capsJSON), model.UpdatedAt, now)
if err != nil {
log.Printf("Failed to upsert model %s: %v", model.Slug, err)
@@ -572,6 +629,106 @@ func formatParamCount(n int64) string {
return fmt.Sprintf("%d", n)
}
// parseParamSizeToFloat extracts numeric value from parameter size strings like "8b", "70b", "1.5b"
// Returns value in billions (e.g., "8b" -> 8.0, "70b" -> 70.0, "500m" -> 0.5)
func parseParamSizeToFloat(s string) float64 {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return 0
}
// Handle suffix
multiplier := 1.0
if strings.HasSuffix(s, "b") {
s = strings.TrimSuffix(s, "b")
} else if strings.HasSuffix(s, "m") {
s = strings.TrimSuffix(s, "m")
multiplier = 0.001 // Convert millions to billions
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f * multiplier
}
return 0
}
// getSizeRange returns the size range category for a given parameter size
// small: ≤3B, medium: 4-13B, large: 14-70B, xlarge: >70B
func getSizeRange(paramSize string) string {
size := parseParamSizeToFloat(paramSize)
if size <= 0 {
return ""
}
if size <= 3 {
return "small"
}
if size <= 13 {
return "medium"
}
if size <= 70 {
return "large"
}
return "xlarge"
}
// modelMatchesSizeRanges checks if any of the model's tags fall within the requested size ranges
// A model matches if at least one of its tags is in any of the requested ranges
func modelMatchesSizeRanges(tags []string, sizeRanges []string) bool {
if len(tags) == 0 || len(sizeRanges) == 0 {
return false
}
for _, tag := range tags {
tagRange := getSizeRange(tag)
if tagRange == "" {
continue
}
for _, sr := range sizeRanges {
if sr == tagRange {
return true
}
}
}
return false
}
// getContextRange returns the context range category for a given context length
// standard: ≤8K, extended: 8K-32K, large: 32K-128K, unlimited: >128K
func getContextRange(ctxLen int64) string {
if ctxLen <= 0 {
return ""
}
if ctxLen <= 8192 {
return "standard"
}
if ctxLen <= 32768 {
return "extended"
}
if ctxLen <= 131072 {
return "large"
}
return "unlimited"
}
// extractFamily extracts the model family from slug (e.g., "llama3.2" -> "llama", "qwen2.5" -> "qwen")
func extractFamily(slug string) string {
// Remove namespace prefix for community models
if idx := strings.LastIndex(slug, "/"); idx != -1 {
slug = slug[idx+1:]
}
// Extract letters before any digits
family := ""
for _, r := range slug {
if r >= '0' && r <= '9' {
break
}
if r == '-' || r == '_' || r == '.' {
break
}
family += string(r)
}
return strings.ToLower(family)
}
// GetModel retrieves a single model from the database
func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*RemoteModel, error) {
row := s.db.QueryRowContext(ctx, `
@@ -584,40 +741,65 @@ func (s *ModelRegistryService) GetModel(ctx context.Context, slug string) (*Remo
return scanRemoteModel(row)
}
// ModelSearchParams holds all search/filter parameters
type ModelSearchParams struct {
Query string
ModelType string
Capabilities []string
SizeRanges []string // small, medium, large, xlarge
ContextRanges []string // standard, extended, large, unlimited
Family string
SortBy string
Limit int
Offset int
}
// SearchModels searches for models in the database
func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, modelType string, capabilities []string, sortBy string, limit, offset int) ([]RemoteModel, int, error) {
return s.SearchModelsAdvanced(ctx, ModelSearchParams{
Query: query,
ModelType: modelType,
Capabilities: capabilities,
SortBy: sortBy,
Limit: limit,
Offset: offset,
})
}
// SearchModelsAdvanced searches for models with all filter options
func (s *ModelRegistryService) SearchModelsAdvanced(ctx context.Context, params ModelSearchParams) ([]RemoteModel, int, error) {
// Build query
baseQuery := `FROM remote_models WHERE 1=1`
args := []any{}
if query != "" {
if params.Query != "" {
baseQuery += ` AND (slug LIKE ? OR name LIKE ? OR description LIKE ?)`
q := "%" + query + "%"
q := "%" + params.Query + "%"
args = append(args, q, q, q)
}
if modelType != "" {
if params.ModelType != "" {
baseQuery += ` AND model_type = ?`
args = append(args, modelType)
args = append(args, params.ModelType)
}
// Filter by capabilities (JSON array contains)
for _, cap := range capabilities {
for _, cap := range params.Capabilities {
// Use JSON contains for SQLite - capabilities column stores JSON array like ["vision","code"]
baseQuery += ` AND capabilities LIKE ?`
args = append(args, `%"`+cap+`"%`)
}
// Get total count
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
// Filter by family (extracted from slug)
if params.Family != "" {
// Match slugs that start with the family name
baseQuery += ` AND (slug LIKE ? OR slug LIKE ?)`
args = append(args, params.Family+"%", "%/"+params.Family+"%")
}
// Build ORDER BY clause based on sort parameter
orderBy := "pull_count DESC" // default: most popular
switch sortBy {
switch params.SortBy {
case "name_asc":
orderBy = "name ASC"
case "name_desc":
@@ -630,12 +812,25 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m
orderBy = "ollama_updated_at DESC NULLS LAST, scraped_at DESC"
}
// Get models
selectQuery := `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
// For size/context filtering, we need to fetch all matching models first
// then filter and paginate in memory (these filters require computed values)
needsPostFilter := len(params.SizeRanges) > 0 || len(params.ContextRanges) > 0
var selectQuery string
if needsPostFilter {
// Fetch all (no limit/offset) for post-filtering
selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy
} else {
// Direct pagination
selectQuery = `SELECT slug, name, description, model_type, architecture, parameter_size,
context_length, embedding_length, quantization, capabilities, default_params,
license, pull_count, tags, tag_sizes, ollama_updated_at, details_fetched_at, scraped_at, url ` +
baseQuery + ` ORDER BY ` + orderBy + ` LIMIT ? OFFSET ?`
args = append(args, params.Limit, params.Offset)
}
rows, err := s.db.QueryContext(ctx, selectQuery, args...)
if err != nil {
@@ -649,10 +844,64 @@ func (s *ModelRegistryService) SearchModels(ctx context.Context, query string, m
if err != nil {
return nil, 0, err
}
// Apply size range filter based on tags
if len(params.SizeRanges) > 0 {
if !modelMatchesSizeRanges(m.Tags, params.SizeRanges) {
continue // Skip models without matching size tags
}
}
// Apply context range filter
if len(params.ContextRanges) > 0 {
modelCtxRange := getContextRange(m.ContextLength)
if modelCtxRange == "" {
continue // Skip models without context info
}
found := false
for _, cr := range params.ContextRanges {
if cr == modelCtxRange {
found = true
break
}
}
if !found {
continue
}
}
models = append(models, *m)
}
return models, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
// Get total after filtering
total := len(models)
// Apply pagination for post-filtered results
if needsPostFilter {
if params.Offset >= len(models) {
models = []RemoteModel{}
} else {
end := params.Offset + params.Limit
if end > len(models) {
end = len(models)
}
models = models[params.Offset:end]
}
} else {
// Get total count from DB for non-post-filtered queries
countQuery := "SELECT COUNT(*) " + baseQuery
// Remove the limit/offset args we added
countArgs := args[:len(args)-2]
if err := s.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
}
return models, total, nil
}
// GetSyncStatus returns info about when models were last synced
@@ -764,31 +1013,53 @@ func scanRemoteModelRows(rows *sql.Rows) (*RemoteModel, error) {
// ListRemoteModelsHandler returns a handler for listing/searching remote models
func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
query := c.Query("search")
modelType := c.Query("type")
sortBy := c.Query("sort") // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc
limit := 50
offset := 0
params := ModelSearchParams{
Query: c.Query("search"),
ModelType: c.Query("type"),
SortBy: c.Query("sort"), // name_asc, name_desc, pulls_asc, pulls_desc, updated_desc
Family: c.Query("family"),
Limit: 50,
Offset: 0,
}
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 200 {
limit = l
params.Limit = l
}
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
offset = o
params.Offset = o
}
// Parse capabilities filter (comma-separated)
var capabilities []string
if caps := c.Query("capabilities"); caps != "" {
for _, cap := range strings.Split(caps, ",") {
cap = strings.TrimSpace(cap)
if cap != "" {
capabilities = append(capabilities, cap)
params.Capabilities = append(params.Capabilities, cap)
}
}
}
models, total, err := s.SearchModels(c.Request.Context(), query, modelType, capabilities, sortBy, limit, offset)
// Parse size range filter (comma-separated: small,medium,large,xlarge)
if sizes := c.Query("sizeRange"); sizes != "" {
for _, sz := range strings.Split(sizes, ",") {
sz = strings.TrimSpace(strings.ToLower(sz))
if sz == "small" || sz == "medium" || sz == "large" || sz == "xlarge" {
params.SizeRanges = append(params.SizeRanges, sz)
}
}
}
// Parse context range filter (comma-separated: standard,extended,large,unlimited)
if ctx := c.Query("contextRange"); ctx != "" {
for _, cr := range strings.Split(ctx, ",") {
cr = strings.TrimSpace(strings.ToLower(cr))
if cr == "standard" || cr == "extended" || cr == "large" || cr == "unlimited" {
params.ContextRanges = append(params.ContextRanges, cr)
}
}
}
models, total, err := s.SearchModelsAdvanced(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -797,8 +1068,8 @@ func (s *ModelRegistryService) ListRemoteModelsHandler() gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{
"models": models,
"total": total,
"limit": limit,
"offset": offset,
"limit": params.Limit,
"offset": params.Offset,
})
}
}
@@ -1138,3 +1409,36 @@ func (s *ModelRegistryService) GetLocalFamiliesHandler() gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"families": families})
}
}
// GetRemoteFamiliesHandler returns unique model families from remote models
// Useful for populating filter dropdowns
func (s *ModelRegistryService) GetRemoteFamiliesHandler() gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := s.db.QueryContext(c.Request.Context(), `SELECT DISTINCT slug FROM remote_models`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
familySet := make(map[string]bool)
for rows.Next() {
var slug string
if err := rows.Scan(&slug); err != nil {
continue
}
family := extractFamily(slug)
if family != "" {
familySet[family] = true
}
}
families := make([]string, 0, len(familySet))
for f := range familySet {
families = append(families, f)
}
sort.Strings(families)
c.JSON(http.StatusOK, gin.H{"families": families})
}
}

View File

@@ -83,6 +83,8 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
// === Remote Models (from ollama.com cache) ===
// List/search remote models (from cache)
models.GET("/remote", modelRegistry.ListRemoteModelsHandler())
// Get unique model families for filter dropdowns
models.GET("/remote/families", modelRegistry.GetRemoteFamiliesHandler())
// Get single model details
models.GET("/remote/:slug", modelRegistry.GetRemoteModelHandler())
// Fetch detailed info from Ollama (requires model to be pulled)

View File

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

View File

@@ -49,11 +49,20 @@ export interface SyncStatus {
/** Sort options for model list */
export type ModelSortOption = 'name_asc' | 'name_desc' | 'pulls_asc' | 'pulls_desc' | 'updated_desc';
/** Size range filter options */
export type SizeRange = 'small' | 'medium' | 'large' | 'xlarge';
/** Context length range filter options */
export type ContextRange = 'standard' | 'extended' | 'large' | 'unlimited';
/** Search/filter options */
export interface ModelSearchOptions {
search?: string;
type?: 'official' | 'community';
capabilities?: string[];
sizeRanges?: SizeRange[];
contextRanges?: ContextRange[];
family?: string;
sort?: ModelSortOption;
limit?: number;
offset?: number;
@@ -73,6 +82,13 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi
if (options.capabilities && options.capabilities.length > 0) {
params.set('capabilities', options.capabilities.join(','));
}
if (options.sizeRanges && options.sizeRanges.length > 0) {
params.set('sizeRange', options.sizeRanges.join(','));
}
if (options.contextRanges && options.contextRanges.length > 0) {
params.set('contextRange', options.contextRanges.join(','));
}
if (options.family) params.set('family', options.family);
if (options.sort) params.set('sort', options.sort);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
@@ -87,6 +103,20 @@ export async function fetchRemoteModels(options: ModelSearchOptions = {}): Promi
return response.json();
}
/**
* Get unique model families for filter dropdowns (remote models)
*/
export async function fetchRemoteFamilies(): Promise<string[]> {
const response = await fetch(`${API_BASE}/remote/families`);
if (!response.ok) {
throw new Error(`Failed to fetch families: ${response.statusText}`);
}
const data = await response.json();
return data.families;
}
/**
* Get a single remote model by slug
*/

View File

@@ -182,6 +182,15 @@
}
});
// Sync custom context limit with settings
$effect(() => {
if (settingsState.useCustomParameters) {
contextManager.setCustomContextLimit(settingsState.num_ctx);
} else {
contextManager.setCustomContextLimit(null);
}
});
// Update context manager when messages change
$effect(() => {
contextManager.updateMessages(chatState.visibleMessages);

View File

@@ -12,6 +12,28 @@
let { model, onSelect }: Props = $props();
/**
* Format a date as relative time (e.g., "2d ago", "3w ago")
*/
function formatRelativeTime(date: string | Date | undefined): string {
if (!date) return '';
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
if (weeks < 4) return `${weeks}w ago`;
return `${months}mo ago`;
}
// Capability badges config (matches ollama.com capabilities)
const capabilityBadges: Record<string, { icon: string; color: string; label: string }> = {
vision: { icon: '👁', color: 'bg-purple-900/50 text-purple-300', label: 'Vision' },
@@ -92,6 +114,16 @@
<span>{formatContextLength(model.contextLength)}</span>
</div>
{/if}
<!-- Last Updated -->
{#if model.ollamaUpdatedAt}
<div class="flex items-center gap-1" title="Last updated on Ollama: {new Date(model.ollamaUpdatedAt).toLocaleDateString()}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{formatRelativeTime(model.ollamaUpdatedAt)}</span>
</div>
{/if}
</div>
<!-- Size Tags -->

View File

@@ -5,6 +5,7 @@
*/
import { settingsState } from '$lib/stores/settings.svelte';
import { modelsState, type ModelDefaults } from '$lib/stores/models.svelte';
import {
PARAMETER_RANGES,
PARAMETER_LABELS,
@@ -16,6 +17,26 @@
// Parameter keys for iteration
const parameterKeys: (keyof ModelParameters)[] = ['temperature', 'top_k', 'top_p', 'num_ctx'];
// Track model defaults for the selected model
let modelDefaults = $state<ModelDefaults>({});
// Fetch model defaults when panel opens or model changes
$effect(() => {
if (settingsState.isPanelOpen && modelsState.selectedId) {
modelsState.fetchModelDefaults(modelsState.selectedId).then((defaults) => {
modelDefaults = defaults;
});
}
});
/**
* Get the default value for a parameter (from model or hardcoded fallback)
*/
function getDefaultValue(key: keyof ModelParameters): number {
const modelValue = modelDefaults[key];
return modelValue ?? DEFAULT_MODEL_PARAMETERS[key];
}
/**
* Format a parameter value for display
*/
@@ -79,7 +100,7 @@
type="button"
role="switch"
aria-checked={settingsState.useCustomParameters}
onclick={() => settingsState.toggleCustomParameters()}
onclick={() => settingsState.toggleCustomParameters(modelDefaults)}
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-theme-secondary {settingsState.useCustomParameters ? 'bg-sky-600' : 'bg-theme-tertiary'}"
>
<span
@@ -93,7 +114,7 @@
{#each parameterKeys as key}
{@const range = PARAMETER_RANGES[key]}
{@const value = getValue(key)}
{@const isDefault = value === DEFAULT_MODEL_PARAMETERS[key]}
{@const isDefault = value === getDefaultValue(key)}
<div>
<div class="mb-1 flex items-center justify-between">
@@ -132,7 +153,7 @@
<div class="mt-4 flex justify-end">
<button
type="button"
onclick={() => settingsState.resetToDefaults()}
onclick={() => settingsState.resetToDefaults(modelDefaults)}
class="rounded px-2 py-1 text-xs text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
>
Reset to defaults

View File

@@ -24,8 +24,14 @@ class ContextManager {
/** Current model name */
currentModel = $state<string>('');
/** Maximum context length for current model */
maxTokens = $state<number>(4096);
/** Maximum context length for current model (from model lookup) */
modelMaxTokens = $state<number>(4096);
/** Custom context limit override (from user settings) */
customMaxTokens = $state<number | null>(null);
/** Effective max tokens (custom override or model default) */
maxTokens = $derived(this.customMaxTokens ?? this.modelMaxTokens);
/**
* Cached token estimates for messages (id -> estimate)
@@ -94,7 +100,15 @@ class ContextManager {
*/
setModel(modelName: string): void {
this.currentModel = modelName;
this.maxTokens = getModelContextLimit(modelName);
this.modelMaxTokens = getModelContextLimit(modelName);
}
/**
* Set custom context limit override
* Pass null to clear and use model default
*/
setCustomContextLimit(tokens: number | null): void {
this.customMaxTokens = tokens;
}
/**

View File

@@ -5,12 +5,15 @@
import {
fetchRemoteModels,
fetchRemoteFamilies,
getSyncStatus,
syncModels,
type RemoteModel,
type SyncStatus,
type ModelSearchOptions,
type ModelSortOption
type ModelSortOption,
type SizeRange,
type ContextRange
} from '$lib/api/model-registry';
/** Store state */
@@ -25,6 +28,10 @@ class ModelRegistryState {
searchQuery = $state('');
modelType = $state<'official' | 'community' | ''>('');
selectedCapabilities = $state<string[]>([]);
selectedSizeRanges = $state<SizeRange[]>([]);
selectedContextRanges = $state<ContextRange[]>([]);
selectedFamily = $state<string>('');
availableFamilies = $state<string[]>([]);
sortBy = $state<ModelSortOption>('pulls_desc');
currentPage = $state(0);
pageSize = $state(24);
@@ -69,6 +76,18 @@ class ModelRegistryState {
options.capabilities = this.selectedCapabilities;
}
if (this.selectedSizeRanges.length > 0) {
options.sizeRanges = this.selectedSizeRanges;
}
if (this.selectedContextRanges.length > 0) {
options.contextRanges = this.selectedContextRanges;
}
if (this.selectedFamily) {
options.family = this.selectedFamily;
}
const response = await fetchRemoteModels(options);
this.models = response.models;
this.total = response.total;
@@ -119,6 +138,68 @@ class ModelRegistryState {
return this.selectedCapabilities.includes(capability);
}
/**
* Toggle a size range filter
*/
async toggleSizeRange(size: SizeRange): Promise<void> {
const index = this.selectedSizeRanges.indexOf(size);
if (index === -1) {
this.selectedSizeRanges = [...this.selectedSizeRanges, size];
} else {
this.selectedSizeRanges = this.selectedSizeRanges.filter((s) => s !== size);
}
this.currentPage = 0;
await this.loadModels();
}
/**
* Check if a size range is selected
*/
hasSizeRange(size: SizeRange): boolean {
return this.selectedSizeRanges.includes(size);
}
/**
* Toggle a context range filter
*/
async toggleContextRange(range: ContextRange): Promise<void> {
const index = this.selectedContextRanges.indexOf(range);
if (index === -1) {
this.selectedContextRanges = [...this.selectedContextRanges, range];
} else {
this.selectedContextRanges = this.selectedContextRanges.filter((r) => r !== range);
}
this.currentPage = 0;
await this.loadModels();
}
/**
* Check if a context range is selected
*/
hasContextRange(range: ContextRange): boolean {
return this.selectedContextRanges.includes(range);
}
/**
* Set family filter
*/
async setFamily(family: string): Promise<void> {
this.selectedFamily = family;
this.currentPage = 0;
await this.loadModels();
}
/**
* Load available families for filter dropdown
*/
async loadFamilies(): Promise<void> {
try {
this.availableFamilies = await fetchRemoteFamilies();
} catch (err) {
console.error('Failed to load families:', err);
}
}
/**
* Set sort order
*/
@@ -200,6 +281,9 @@ class ModelRegistryState {
this.searchQuery = '';
this.modelType = '';
this.selectedCapabilities = [];
this.selectedSizeRanges = [];
this.selectedContextRanges = [];
this.selectedFamily = '';
this.sortBy = 'pulls_desc';
this.currentPage = 0;
await this.loadModels();
@@ -209,7 +293,7 @@ class ModelRegistryState {
* Initialize the store
*/
async init(): Promise<void> {
await Promise.all([this.loadSyncStatus(), this.loadModels()]);
await Promise.all([this.loadSyncStatus(), this.loadModels(), this.loadFamilies()]);
}
}

View File

@@ -65,6 +65,14 @@ export interface ModelUpdateStatus {
localModifiedAt: string;
}
/** Model default parameters from Ollama */
export interface ModelDefaults {
temperature?: number;
top_k?: number;
top_p?: number;
num_ctx?: number;
}
/** Models state class with reactive properties */
export class ModelsState {
// Core state
@@ -81,6 +89,10 @@ export class ModelsState {
private capabilitiesCache = $state<Map<string, OllamaCapability[]>>(new Map());
private capabilitiesFetching = new Set<string>();
// Model defaults cache: modelName -> default parameters
private modelDefaultsCache = $state<Map<string, ModelDefaults>>(new Map());
private modelDefaultsFetching = new Set<string>();
// Derived: Currently selected model
selected = $derived.by(() => {
if (!this.selectedId) return null;
@@ -429,6 +441,99 @@ export class ModelsState {
}
return count;
}
// =========================================================================
// Model Defaults
// =========================================================================
/**
* Parse model parameters from the Ollama show response
* The parameters field contains lines like "temperature 0.7" or "num_ctx 4096"
*/
private parseModelParameters(parametersStr: string): ModelDefaults {
const defaults: ModelDefaults = {};
if (!parametersStr) return defaults;
const lines = parametersStr.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Parse "key value" format
const spaceIndex = trimmed.indexOf(' ');
if (spaceIndex === -1) continue;
const key = trimmed.substring(0, spaceIndex).toLowerCase();
const value = trimmed.substring(spaceIndex + 1).trim();
switch (key) {
case 'temperature':
defaults.temperature = parseFloat(value);
break;
case 'top_k':
defaults.top_k = parseInt(value, 10);
break;
case 'top_p':
defaults.top_p = parseFloat(value);
break;
case 'num_ctx':
defaults.num_ctx = parseInt(value, 10);
break;
}
}
return defaults;
}
/**
* Fetch model defaults from Ollama /api/show
*/
async fetchModelDefaults(modelName: string): Promise<ModelDefaults> {
// Check cache first
const cached = this.modelDefaultsCache.get(modelName);
if (cached) return cached;
// Avoid duplicate fetches
if (this.modelDefaultsFetching.has(modelName)) {
await new Promise((r) => setTimeout(r, 100));
return this.modelDefaultsCache.get(modelName) ?? {};
}
this.modelDefaultsFetching.add(modelName);
try {
const response = await ollamaClient.showModel(modelName);
const defaults = this.parseModelParameters(response.parameters);
// Update cache reactively
const newCache = new Map(this.modelDefaultsCache);
newCache.set(modelName, defaults);
this.modelDefaultsCache = newCache;
return defaults;
} catch (err) {
console.warn(`Failed to fetch defaults for ${modelName}:`, err);
return {};
} finally {
this.modelDefaultsFetching.delete(modelName);
}
}
/**
* Get cached model defaults (returns empty if not fetched)
*/
getModelDefaults(modelName: string): ModelDefaults {
return this.modelDefaultsCache.get(modelName) ?? {};
}
/**
* Get defaults for selected model
*/
get selectedModelDefaults(): ModelDefaults {
if (!this.selectedId) return {};
return this.modelDefaultsCache.get(this.selectedId) ?? {};
}
}
/** Singleton models state instance */

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_CHAT_SETTINGS,
PARAMETER_RANGES
} from '$lib/types/settings';
import type { ModelDefaults } from './models.svelte';
const STORAGE_KEY = 'vessel-settings';
@@ -79,12 +80,30 @@ export class SettingsState {
/**
* Toggle whether to use custom parameters
* When enabling, optionally initialize from model defaults
*/
toggleCustomParameters(): void {
toggleCustomParameters(modelDefaults?: ModelDefaults): void {
this.useCustomParameters = !this.useCustomParameters;
// When enabling custom parameters, initialize from model defaults if provided
if (this.useCustomParameters && modelDefaults) {
this.initializeFromModelDefaults(modelDefaults);
}
this.saveToStorage();
}
/**
* Initialize parameters from model defaults
* Falls back to hardcoded defaults for any missing values
*/
initializeFromModelDefaults(modelDefaults: ModelDefaults): void {
this.temperature = modelDefaults.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = modelDefaults.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = modelDefaults.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = modelDefaults.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
}
/**
* Update a single parameter
*/
@@ -112,14 +131,13 @@ export class SettingsState {
}
/**
* Reset all parameters to defaults
* Reset all parameters to model defaults (or hardcoded defaults if not available)
*/
resetToDefaults(): void {
this.temperature = DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = DEFAULT_MODEL_PARAMETERS.num_ctx;
this.useCustomParameters = false;
resetToDefaults(modelDefaults?: ModelDefaults): void {
this.temperature = modelDefaults?.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = modelDefaults?.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = modelDefaults?.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = modelDefaults?.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
this.saveToStorage();
}

View File

@@ -292,7 +292,9 @@ class MathParser {
const mathParser = new MathParser();
const calculateHandler: BuiltinToolHandler<CalculateArgs> = (args) => {
const { expression, precision = 10 } = args;
const { expression } = args;
// Coerce to number - Ollama models sometimes output numbers as strings
const precision = Number(args.precision) || 10;
try {
const result = mathParser.parse(expression);
@@ -423,7 +425,10 @@ async function fetchViaProxy(url: string, maxLength: number, timeout: number): P
}
const fetchUrlHandler: BuiltinToolHandler<FetchUrlArgs> = async (args) => {
const { url, extract = 'text', maxLength = 50000, timeout = 30 } = args;
const { url, extract = 'text' } = args;
// Coerce to numbers - Ollama models sometimes output numbers as strings
const maxLength = Number(args.maxLength) || 50000;
const timeout = Number(args.timeout) || 30;
try {
const parsedUrl = new URL(url);
@@ -683,7 +688,10 @@ const webSearchDefinition: ToolDefinition = {
};
const webSearchHandler: BuiltinToolHandler<WebSearchArgs> = async (args) => {
const { query, maxResults = 5, site, freshness, region, timeout } = args;
const { query, site, freshness, region } = args;
// Coerce to numbers - Ollama models sometimes output numbers as strings
const maxResults = Number(args.maxResults) || 5;
const timeout = Number(args.timeout) || undefined;
if (!query || query.trim() === '') {
return { error: 'Search query is required' };

View File

@@ -641,7 +641,7 @@
</div>
<!-- Capability Filters (matches ollama.com capabilities) -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Capabilities:</span>
<button
type="button"
@@ -693,14 +693,74 @@
<span>☁️</span>
<span>Cloud</span>
</button>
</div>
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<!-- Size Range Filters -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-theme-muted">Size:</span>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('small')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('small')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
≤3B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('medium')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('medium')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
4-13B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('large')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('large')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
14-70B
</button>
<button
type="button"
onclick={() => modelRegistry.toggleSizeRange('xlarge')}
class="rounded-full px-3 py-1 text-sm transition-colors {modelRegistry.hasSizeRange('xlarge')
? 'bg-emerald-600 text-theme-primary'
: 'bg-theme-secondary text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
>
>70B
</button>
</div>
<!-- Family Filter + Clear All -->
<div class="mb-6 flex flex-wrap items-center gap-4">
{#if modelRegistry.availableFamilies.length > 0}
<div class="flex items-center gap-2">
<span class="text-sm text-theme-muted">Family:</span>
<select
value={modelRegistry.selectedFamily}
onchange={(e) => modelRegistry.setFamily((e.target as HTMLSelectElement).value)}
class="rounded-lg border border-theme bg-theme-secondary px-3 py-1.5 text-sm text-theme-primary focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">All Families</option>
{#each modelRegistry.availableFamilies as family}
<option value={family}>{family}</option>
{/each}
</select>
</div>
{/if}
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.selectedSizeRanges.length > 0 || modelRegistry.selectedFamily || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<button
type="button"
onclick={() => { modelRegistry.clearFilters(); searchInput = ''; }}
class="ml-2 text-sm text-theme-muted hover:text-theme-primary"
class="text-sm text-theme-muted hover:text-theme-primary"
>
Clear filters
Clear all filters
</button>
{/if}
</div>

View File

@@ -4,11 +4,12 @@ import { defineConfig } from 'vite';
// Use environment variable or default to localhost (works with host network mode)
const ollamaUrl = process.env.OLLAMA_API_URL || 'http://localhost:11434';
const backendUrl = process.env.BACKEND_URL || 'http://localhost:9090';
const devPort = parseInt(process.env.DEV_PORT || '7842', 10);
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 7842,
port: devPort,
proxy: {
// Backend health check
'/health': {