9 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
a552f4a223 chore: bump version to 0.4.1
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 20:36:03 +01:00
4a9e45b40b fix: persist toolCalls to database for reload persistence
Tool usage was not showing after page reload because the toolCalls
field was not being included when saving assistant messages to the
database. Now toolCalls are properly persisted and restored.
2026-01-02 20:34:53 +01:00
16 changed files with 758 additions and 61 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.0"
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.0",
"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);
@@ -604,12 +613,16 @@
// The results are stored in toolCalls and displayed by ToolCallDisplay
}
// Persist the assistant message (without flooding text content)
// Persist the assistant message (including toolCalls for reload persistence)
if (conversationId && assistantNode) {
const parentOfAssistant = assistantNode.parentId;
await addStoredMessage(
conversationId,
{ role: 'assistant', content: assistantNode.message.content },
{
role: 'assistant',
content: assistantNode.message.content,
toolCalls: assistantNode.message.toolCalls
},
parentOfAssistant,
assistantMessageId
);

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': {