Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04c3018360 | |||
| 2699f1cd5c | |||
| 9f313e6599 | |||
| 802db229a6 | |||
| 14b566ce2a | |||
| 7ef29aba37 | |||
| 3c8d811cdc | |||
| 5cab71dd78 | |||
| 41bee19f6b | |||
| f4febf8973 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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.7"
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vessel",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user