8 Commits

Author SHA1 Message Date
e19b6330e9 chore: bump version to 0.4.8
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 15:32:04 +01:00
c194a4e0e9 fix: include custom tools in Ollama API requests
Custom tools were displayed as enabled in the UI but never sent to
Ollama because getEnabledToolDefinitions() only queried the builtin
tool registry. Now iterates customTools and includes enabled ones.

Fixes #4
2026-01-03 15:29:25 +01:00
04c3018360 chore: bump version to 0.4.7
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 22:42:35 +01:00
2699f1cd5c fix: handle null updates array and show capabilities for local models
- Fix TypeError when check updates returns null updates array
- Display verified capabilities from Ollama runtime in Local Models tab
- Fetch all model capabilities on page mount
- Add data-dev to gitignore
2026-01-02 22:41:37 +01:00
9f313e6599 feat: verify model capabilities from Ollama runtime
Some checks failed
Create Release / release (push) Has been cancelled
- Add capability verification for installed models using /api/show
- SyncModels now updates real capabilities when fetchDetails=true
- Model browser shows verified/unverified badges for capabilities
- Add info notice that capabilities are sourced from ollama.com
- Fix incorrect capability data (e.g., deepseek-r1 "tools" badge)

Capabilities from ollama.com website may be inaccurate. Once a model
is installed, Vessel fetches actual capabilities from Ollama runtime
and displays a "verified" badge in the model details panel.
2026-01-02 22:35:03 +01:00
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
13 changed files with 739 additions and 58 deletions

6
.gitignore vendored
View File

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

View File

@@ -18,7 +18,7 @@ import (
)
// Version is set at build time via -ldflags, or defaults to dev
var Version = "0.4.3"
var Version = "0.4.8"
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)
@@ -434,6 +491,55 @@ func (s *ModelRegistryService) SyncModels(ctx context.Context, fetchDetails bool
count++
}
// If fetchDetails is true and we have an Ollama client, update capabilities
// for installed models using the actual /api/show response (more accurate than scraped data)
if fetchDetails && s.ollamaClient != nil {
installedModels, err := s.ollamaClient.List(ctx)
if err != nil {
log.Printf("Warning: failed to list installed models for capability sync: %v", err)
} else {
log.Printf("Syncing capabilities for %d installed models", len(installedModels.Models))
for _, installed := range installedModels.Models {
select {
case <-ctx.Done():
return count, ctx.Err()
default:
}
// Extract base model name (e.g., "deepseek-r1" from "deepseek-r1:14b")
modelName := installed.Model
baseName := strings.Split(modelName, ":")[0]
// Fetch real capabilities from Ollama
details, err := s.fetchModelDetails(ctx, modelName)
if err != nil {
log.Printf("Warning: failed to fetch details for %s: %v", modelName, err)
continue
}
// Extract capabilities from the actual Ollama response
capabilities := []string{}
if details.Capabilities != nil {
for _, cap := range details.Capabilities {
capabilities = append(capabilities, string(cap))
}
}
capsJSON, _ := json.Marshal(capabilities)
// Update capabilities for the base model name
_, err = s.db.ExecContext(ctx, `
UPDATE remote_models SET capabilities = ? WHERE slug = ?
`, string(capsJSON), baseName)
if err != nil {
log.Printf("Warning: failed to update capabilities for %s: %v", baseName, err)
} else {
log.Printf("Updated capabilities for %s: %v", baseName, capabilities)
}
}
}
}
return count, nil
}
@@ -572,6 +678,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 +790,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 +861,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 +893,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 +1062,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 +1117,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 +1458,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.3",
"version": "0.4.8",
"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
*/
@@ -135,9 +165,11 @@ export async function fetchTagSizes(slug: string): Promise<RemoteModel> {
/**
* Sync models from ollama.com
* @param fetchDetails - If true, also fetches real capabilities from Ollama for installed models
*/
export async function syncModels(): Promise<SyncResponse> {
const response = await fetch(`${API_BASE}/remote/sync`, {
export async function syncModels(fetchDetails: boolean = true): Promise<SyncResponse> {
const url = fetchDetails ? `${API_BASE}/remote/sync?details=true` : `${API_BASE}/remote/sync`;
const response = await fetch(url, {
method: 'POST'
});

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

@@ -146,7 +146,8 @@ class LocalModelsState {
const response = await checkForUpdates();
this.updatesAvailable = response.updatesAvailable;
this.modelsWithUpdates = new Set(response.updates.map(m => m.name));
// Handle null/undefined updates array from API
this.modelsWithUpdates = new Set((response.updates ?? []).map(m => m.name));
return response;
} catch (err) {

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

@@ -110,8 +110,25 @@ class ToolsState {
return [];
}
const definitions = toolRegistry.getDefinitions();
return definitions.filter(def => this.isToolEnabled(def.function.name));
// Get enabled builtin tools
const builtinDefs = toolRegistry.getDefinitions();
const enabled = builtinDefs.filter(def => this.isToolEnabled(def.function.name));
// Add enabled custom tools
for (const custom of this.customTools) {
if (custom.enabled && this.isToolEnabled(custom.name)) {
enabled.push({
type: 'function',
function: {
name: custom.name,
description: custom.description,
parameters: custom.parameters
}
});
}
}
return enabled;
}
/**

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

@@ -40,12 +40,14 @@
let pullProgress = $state<{ status: string; completed?: number; total?: number } | null>(null);
let pullError = $state<string | null>(null);
let loadingSizes = $state(false);
let capabilitiesVerified = $state(false); // True if capabilities come from Ollama (installed model)
async function handleSelectModel(model: RemoteModel): Promise<void> {
selectedModel = model;
selectedTag = model.tags[0] || '';
pullProgress = null;
pullError = null;
capabilitiesVerified = false;
// Fetch tag sizes if not already loaded
if (!model.tagSizes || Object.keys(model.tagSizes).length === 0) {
@@ -60,6 +62,21 @@
loadingSizes = false;
}
}
// Try to fetch real capabilities from Ollama if model is installed locally
// This overrides scraped capabilities from ollama.com with accurate runtime data
try {
const realCapabilities = await modelsState.fetchCapabilities(model.slug);
// fetchCapabilities returns empty array on error, but we check hasCapability to confirm model exists
if (modelsState.hasCapability(model.slug, 'completion') || realCapabilities.length > 0) {
// Model is installed - use real capabilities from Ollama
selectedModel = { ...selectedModel!, capabilities: realCapabilities };
capabilitiesVerified = true;
}
} catch {
// Model not installed locally - keep scraped capabilities
capabilitiesVerified = false;
}
}
function closeDetails(): void {
@@ -219,7 +236,10 @@
// Initialize stores (backend handles heavy operations)
localModelsState.init();
modelRegistry.init();
modelsState.refresh();
modelsState.refresh().then(() => {
// Fetch capabilities for all installed models
modelsState.fetchAllCapabilities();
});
});
</script>
@@ -476,6 +496,7 @@
{:else}
<div class="space-y-2">
{#each localModelsState.models as model (model.name)}
{@const caps = modelsState.getCapabilities(model.name) ?? []}
<div class="group rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:border-theme-subtle">
<div class="flex items-center justify-between">
<div class="flex-1">
@@ -496,6 +517,36 @@
<span>Parameters: {model.parameterSize}</span>
<span>Quantization: {model.quantizationLevel}</span>
</div>
<!-- Capabilities (from Ollama runtime - verified) -->
{#if caps.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#if caps.includes('vision')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-purple-900/50 text-purple-300">
<span>👁</span><span>Vision</span>
</span>
{/if}
{#if caps.includes('tools')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-300">
<span>🔧</span><span>Tools</span>
</span>
{/if}
{#if caps.includes('thinking')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-pink-900/50 text-pink-300">
<span>🧠</span><span>Thinking</span>
</span>
{/if}
{#if caps.includes('embedding')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-amber-900/50 text-amber-300">
<span>📊</span><span>Embedding</span>
</span>
{/if}
{#if caps.includes('code')}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-emerald-900/50 text-emerald-300">
<span>💻</span><span>Code</span>
</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if deleteConfirm === model.name}
@@ -641,7 +692,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"
@@ -694,13 +745,81 @@
<span>Cloud</span>
</button>
{#if modelRegistry.selectedCapabilities.length > 0 || modelRegistry.modelType || modelRegistry.searchQuery || modelRegistry.sortBy !== 'pulls_desc'}
<!-- Capability info notice -->
<span class="ml-2 text-xs text-theme-muted" title="Capability data is sourced from ollama.com and may not be accurate. Actual capabilities are verified once a model is installed locally.">
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-3.5 w-3.5 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="opacity-60">from ollama.com</span>
</span>
</div>
<!-- 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>
@@ -826,14 +945,40 @@
{/if}
<!-- Capabilities -->
{#if selectedModel.capabilities.length > 0}
{#if selectedModel.capabilities.length > 0 || !capabilitiesVerified}
<div class="mb-6">
<h3 class="mb-2 text-sm font-medium text-theme-secondary">Capabilities</h3>
<div class="flex flex-wrap gap-2">
{#each selectedModel.capabilities as cap}
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
{/each}
</div>
<h3 class="mb-2 flex items-center gap-2 text-sm font-medium text-theme-secondary">
<span>Capabilities</span>
{#if capabilitiesVerified}
<span class="inline-flex items-center gap-1 rounded bg-green-900/30 px-1.5 py-0.5 text-xs text-green-400" title="Capabilities verified from installed model">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
verified
</span>
{:else}
<span class="inline-flex items-center gap-1 rounded bg-amber-900/30 px-1.5 py-0.5 text-xs text-amber-400" title="Capabilities sourced from ollama.com - install model for verified data">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
unverified
</span>
{/if}
</h3>
{#if selectedModel.capabilities.length > 0}
<div class="flex flex-wrap gap-2">
{#each selectedModel.capabilities as cap}
<span class="rounded bg-theme-tertiary px-2 py-1 text-xs text-theme-secondary">{cap}</span>
{/each}
</div>
{:else}
<p class="text-xs text-theme-muted">No capabilities reported</p>
{/if}
{#if !capabilitiesVerified}
<p class="mt-2 text-xs text-theme-muted">
Install model to verify actual capabilities
</p>
{/if}
</div>
{/if}

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