11 Commits

Author SHA1 Message Date
1063bec248 chore: bump version to 0.4.9
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-03 18:26:40 +01:00
cf4981f3b2 feat: add auto-compact, settings page, and message virtualization
- Add auto-compact feature with configurable threshold (50-90%)
- Convert settings modal to full /settings page with organized sections
- Add Memory Management settings (auto-compact toggle, threshold, preserve count)
- Add inline SummarizationIndicator shown where compaction occurred
- Add VirtualMessageList with fallback for long conversation performance
- Trigger auto-compact after assistant responses when threshold reached
2026-01-03 18:26:11 +01:00
7cc0df2c78 ci: sync GitHub release notes from Gitea 2026-01-03 15:48:50 +01:00
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
28 changed files with 1693 additions and 289 deletions

View File

@@ -17,11 +17,30 @@ jobs:
with:
fetch-depth: 0
- name: Wait for Gitea release
run: sleep 60
- name: Fetch release notes from Gitea
id: gitea_notes
env:
TAG_NAME: ${{ github.ref_name }}
run: |
NOTES=$(curl -s "https://somegit.dev/api/v1/repos/vikingowl/vessel/releases/tags/${TAG_NAME}" | jq -r '.body // empty')
if [ -n "$NOTES" ]; then
echo "found=true" >> $GITHUB_OUTPUT
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "notes=See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases/tag/${TAG_NAME}) for detailed information." >> $GITHUB_OUTPUT
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
body: |
See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases) for detailed information.
body: ${{ steps.gitea_notes.outputs.notes }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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.9"
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,12 +1,12 @@
{
"name": "vessel",
"version": "0.3.0",
"version": "0.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vessel",
"version": "0.3.0",
"version": "0.4.8",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
@@ -15,6 +15,8 @@
"@skeletonlabs/skeleton": "^2.10.0",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
@@ -1739,6 +1741,32 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tanstack/svelte-virtual": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.15.tgz",
"integrity": "sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.15"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.15",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.15.tgz",
"integrity": "sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.4.3",
"version": "0.4.9",
"private": true,
"type": "module",
"scripts": {
@@ -37,10 +37,12 @@
"@codemirror/lang-python": "^6.1.7",
"@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
"codemirror": "^6.0.1",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@tanstack/svelte-virtual": "^3.13.15",
"@tanstack/virtual-core": "^3.13.15",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1",
"dexie": "^4.0.10",
"dompurify": "^3.2.0",
"marked": "^15.0.0",

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

@@ -22,7 +22,7 @@
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
import type { OllamaMessage, OllamaToolCall, OllamaToolDefinition } from '$lib/ollama';
import type { Conversation } from '$lib/types/conversation';
import MessageList from './MessageList.svelte';
import VirtualMessageList from './VirtualMessageList.svelte';
import ChatInput from './ChatInput.svelte';
import EmptyState from './EmptyState.svelte';
import ContextUsageBar from './ContextUsageBar.svelte';
@@ -271,6 +271,49 @@
}
}
/**
* Handle automatic compaction of older messages
* Called after assistant response completes when auto-compact is enabled
*/
async function handleAutoCompact(): Promise<void> {
// Check if auto-compact should be triggered
if (!contextManager.shouldAutoCompact()) return;
const selectedModel = modelsState.selectedId;
if (!selectedModel || isSummarizing) return;
const messages = chatState.visibleMessages;
const preserveCount = contextManager.getAutoCompactPreserveCount();
const { toSummarize } = selectMessagesForSummarization(messages, 0, preserveCount);
if (toSummarize.length < 2) return;
isSummarizing = true;
try {
// Generate summary using the LLM
const summary = await generateSummary(toSummarize, selectedModel);
// Mark original messages as summarized
const messageIdsToSummarize = toSummarize.map((node) => node.id);
chatState.markAsSummarized(messageIdsToSummarize);
// Insert the summary message (inline indicator will be shown by MessageList)
chatState.insertSummaryMessage(summary);
// Force context recalculation
contextManager.updateMessages(chatState.visibleMessages, true);
// Subtle notification for auto-compact (inline indicator is the primary feedback)
console.log(`[Auto-compact] Summarized ${toSummarize.length} messages`);
} catch (error) {
console.error('[Auto-compact] Failed:', error);
// Silent failure for auto-compact - don't interrupt user flow
} finally {
isSummarizing = false;
}
}
// =========================================================================
// Context Full Modal Handlers
// =========================================================================
@@ -549,6 +592,9 @@
conversationsState.update(conversationId, {});
}
}
// Check for auto-compact after response completes
await handleAutoCompact();
},
onError: (error) => {
console.error('Streaming error:', error);
@@ -826,7 +872,7 @@
<div class="flex h-full flex-col bg-theme-primary">
{#if hasMessages}
<div class="flex-1 overflow-hidden">
<MessageList
<VirtualMessageList
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
showThinking={thinkingEnabled}

View File

@@ -7,6 +7,7 @@
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
interface Props {
onRegenerate?: () => void;
@@ -208,6 +209,10 @@
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#each chatState.visibleMessages as node, index (node.id)}
<!-- Show summarization indicator before summary messages -->
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
/**
* SummarizationIndicator - Visual marker showing where conversation was summarized
* Displayed in the message list to indicate context compaction occurred
*/
</script>
<div class="flex items-center gap-3 py-4" role="separator" aria-label="Conversation summarized">
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
<div class="flex items-center gap-2 text-xs text-emerald-500">
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<span>Earlier messages summarized</span>
</div>
<div class="flex-1 border-t border-dashed border-emerald-500/30"></div>
</div>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
/**
* VirtualMessageList - Virtualized message list for large conversations
* Only renders visible messages for performance with long chats
*
* Uses @tanstack/svelte-virtual for virtualization.
* Falls back to regular rendering if virtualization fails.
*/
import { createVirtualizer } from '@tanstack/svelte-virtual';
import { chatState } from '$lib/stores';
import type { MessageNode, BranchInfo } from '$lib/types';
import MessageItem from './MessageItem.svelte';
import SummarizationIndicator from './SummarizationIndicator.svelte';
import { onMount } from 'svelte';
interface Props {
onRegenerate?: () => void;
onEditMessage?: (messageId: string, newContent: string) => void;
showThinking?: boolean;
}
const { onRegenerate, onEditMessage, showThinking = true }: Props = $props();
// Container reference
let scrollContainer: HTMLDivElement | null = $state(null);
// Track if component is mounted (scroll container available)
let isMounted = $state(false);
// Track user scroll state
let userScrolledAway = $state(false);
let autoScrollEnabled = $state(true);
let wasStreaming = false;
// Height cache for measured items (message ID -> height)
const heightCache = new Map<string, number>();
// Default estimated height for messages
const DEFAULT_ITEM_HEIGHT = 150;
// Threshold for scroll detection
const SCROLL_THRESHOLD = 100;
// Get visible messages
const messages = $derived(chatState.visibleMessages);
// Set mounted after component mounts
onMount(() => {
isMounted = true;
});
// Create virtualizer - only functional after mount when scrollContainer exists
const virtualizer = createVirtualizer({
get count() {
return messages.length;
},
getScrollElement: () => scrollContainer,
estimateSize: (index: number) => {
const msg = messages[index];
if (!msg) return DEFAULT_ITEM_HEIGHT;
return heightCache.get(msg.id) ?? DEFAULT_ITEM_HEIGHT;
},
overscan: 5,
});
// Get virtual items with fallback
const virtualItems = $derived.by(() => {
if (!isMounted || !scrollContainer) {
return [];
}
return $virtualizer.getVirtualItems();
});
// Check if we should use fallback (non-virtual) rendering
const useFallback = $derived(
messages.length > 0 && virtualItems.length === 0 && isMounted
);
// Track conversation changes to clear cache
let lastConversationId: string | null = null;
$effect(() => {
const currentId = chatState.conversationId;
if (currentId !== lastConversationId) {
heightCache.clear();
lastConversationId = currentId;
}
});
// Force measure after mount and when scroll container becomes available
$effect(() => {
if (isMounted && scrollContainer && messages.length > 0) {
// Use setTimeout to ensure DOM is fully ready
setTimeout(() => {
$virtualizer.measure();
}, 0);
}
});
// Handle streaming scroll behavior
$effect(() => {
const isStreaming = chatState.isStreaming;
if (isStreaming && !wasStreaming) {
autoScrollEnabled = true;
if (!userScrolledAway && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
}
wasStreaming = isStreaming;
});
// Scroll to bottom during streaming
$effect(() => {
const buffer = chatState.streamBuffer;
const isStreaming = chatState.isStreaming;
if (isStreaming && buffer && autoScrollEnabled && scrollContainer) {
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
}
});
}
});
// Scroll when new messages are added
let previousMessageCount = 0;
$effect(() => {
const currentCount = messages.length;
if (currentCount > previousMessageCount && currentCount > 0 && scrollContainer) {
autoScrollEnabled = true;
userScrolledAway = false;
requestAnimationFrame(() => {
if (useFallback) {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight });
} else {
$virtualizer.scrollToIndex(currentCount - 1, { align: 'end' });
}
});
}
previousMessageCount = currentCount;
});
// Handle scroll events
function handleScroll(): void {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
userScrolledAway = scrollHeight - scrollTop - clientHeight > SCROLL_THRESHOLD;
if (userScrolledAway && chatState.isStreaming) {
autoScrollEnabled = false;
}
}
// Scroll to bottom button handler
function scrollToBottom(): void {
if (!scrollContainer) return;
if (useFallback) {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else if (messages.length > 0) {
$virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'smooth' });
}
}
// Measure item height after render (for virtualized mode)
function measureItem(node: HTMLElement, index: number) {
const msg = messages[index];
if (!msg) return { destroy: () => {} };
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0 && heightCache.get(msg.id) !== height) {
heightCache.set(msg.id, height);
$virtualizer.measure();
}
}
});
resizeObserver.observe(node);
// Initial measurement
const height = node.getBoundingClientRect().height;
if (height > 0) {
heightCache.set(msg.id, height);
}
return {
destroy() {
resizeObserver.disconnect();
}
};
}
// Get branch info for a message
function getBranchInfo(node: MessageNode): BranchInfo | null {
const info = chatState.getBranchInfo(node.id);
if (info && info.totalCount > 1) {
return info;
}
return null;
}
// Handle branch switch
function handleBranchSwitch(messageId: string, direction: 'prev' | 'next'): void {
chatState.switchBranch(messageId, direction);
}
// Check if message is streaming
function isStreamingMessage(node: MessageNode): boolean {
return chatState.isStreaming && chatState.streamingMessageId === node.id;
}
// Check if message is last
function isLastMessage(index: number): boolean {
return index === messages.length - 1;
}
// Show scroll button
const showScrollButton = $derived(userScrolledAway && messages.length > 0);
</script>
<div class="relative h-full">
<div
bind:this={scrollContainer}
onscroll={handleScroll}
class="h-full overflow-y-auto"
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div class="mx-auto max-w-4xl px-4 py-6">
{#if useFallback}
<!-- Fallback: Regular rendering when virtualization isn't working -->
{#each messages as node, index (node.id)}
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
{/each}
{:else}
<!-- Virtualized rendering -->
<div
style="height: {$virtualizer.getTotalSize()}px; width: 100%; position: relative;"
>
{#each virtualItems as virtualRow (virtualRow.key)}
{@const node = messages[virtualRow.index]}
{@const index = virtualRow.index}
{#if node}
<div
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualRow.start}px);"
use:measureItem={index}
>
{#if node.message.isSummary}
<SummarizationIndicator />
{/if}
<MessageItem
{node}
branchInfo={getBranchInfo(node)}
isStreaming={isStreamingMessage(node)}
isLast={isLastMessage(index)}
{showThinking}
onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)}
onRegenerate={onRegenerate}
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<!-- Scroll to bottom button -->
{#if showScrollButton}
<button
type="button"
onclick={scrollToBottom}
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-theme-tertiary px-4 py-2 text-sm text-theme-secondary shadow-lg transition-all hover:bg-theme-secondary"
aria-label="Scroll to latest message"
>
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L10 15.586l4.293-4.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 18z" clip-rule="evenodd" />
</svg>
Jump to latest
</span>
</button>
{/if}
</div>

View File

@@ -8,13 +8,9 @@
import SidenavHeader from './SidenavHeader.svelte';
import SidenavSearch from './SidenavSearch.svelte';
import ConversationList from './ConversationList.svelte';
import { SettingsModal } from '$lib/components/shared';
// Check if a path is active
const isActive = (path: string) => $page.url.pathname === path;
// Settings modal state
let settingsOpen = $state(false);
</script>
<!-- Overlay for mobile (closes sidenav when clicking outside) -->
@@ -137,11 +133,10 @@
<span>Prompts</span>
</a>
<!-- Settings button -->
<button
type="button"
onclick={() => (settingsOpen = true)}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-hover hover:text-theme-primary"
<!-- Settings link -->
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors {isActive('/settings') ? 'bg-gray-500/20 text-gray-600 dark:bg-gray-700/30 dark:text-gray-300' : 'text-theme-muted hover:bg-theme-hover hover:text-theme-primary'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -159,10 +154,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span>Settings</span>
</button>
</a>
</div>
</div>
</aside>
<!-- Settings Modal -->
<SettingsModal isOpen={settingsOpen} onClose={() => (settingsOpen = false)} />

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

@@ -1,203 +0,0 @@
<script lang="ts">
/**
* SettingsModal - Application settings dialog
* Handles theme, model defaults, and other preferences
*/
import { modelsState, uiState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
// Settings state (mirrors global state for editing)
let defaultModel = $state<string | null>(null);
// Sync with global state when modal opens
$effect(() => {
if (isOpen) {
defaultModel = modelsState.selectedId;
}
});
/**
* Save settings and close modal
*/
function handleSave(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
onClose();
}
/**
* Handle backdrop click
*/
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
/**
* Handle escape key
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
const modifierKey = getPrimaryModifierDisplay();
</script>
{#if isOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<!-- Modal -->
<div
class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="settings-title" class="text-lg font-semibold text-theme-primary">Settings</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-secondary"
aria-label="Close settings"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-6 p-6">
<!-- Appearance Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Appearance</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
</div>
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="relative inline-flex h-6 w-11 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-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={uiState.darkMode}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
</div>
<button
type="button"
onclick={() => uiState.useSystemTheme()}
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Model Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Default Model</h3>
<div class="space-y-4">
<div>
<select
bind:value={defaultModel}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
{#each modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
<p class="mt-1 text-sm text-theme-muted">Model used for new conversations</p>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">Keyboard Shortcuts</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between text-theme-secondary">
<span>New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between text-theme-secondary">
<span>New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-0.5 font-mono text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-theme-muted">About</h3>
<div class="rounded-lg bg-theme-tertiary/50 p-4">
<p class="font-medium text-theme-secondary">Vessel</p>
<p class="mt-1 text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</section>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
<button
type="button"
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary hover:bg-theme-tertiary"
>
Cancel
</button>
<button
type="button"
onclick={handleSave}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Save Changes
</button>
</div>
</div>
</div>
{/if}

View File

@@ -10,6 +10,5 @@ export { default as ToastContainer } from './ToastContainer.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as MessageSkeleton } from './MessageSkeleton.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
export { default as SettingsModal } from './SettingsModal.svelte';
export { default as ShortcutsModal } from './ShortcutsModal.svelte';
export { default as SearchModal } from './SearchModal.svelte';

View File

@@ -9,6 +9,7 @@ import type { MessageNode } from '$lib/types/chat.js';
import type { ContextUsage, TokenEstimate, MessageWithTokens } from './types.js';
import { estimateMessageTokens, estimateFormatOverhead, formatTokenCount } from './tokenizer.js';
import { getModelContextLimit, formatContextSize } from './model-limits.js';
import { settingsState } from '$lib/stores/settings.svelte.js';
/** Warning threshold as percentage of context (0.85 = 85%) */
const WARNING_THRESHOLD = 0.85;
@@ -252,6 +253,43 @@ class ContextManager {
this.tokenCache.clear();
this.messagesWithTokens = [];
}
/**
* Check if auto-compact should be triggered
* Returns true if:
* - Auto-compact is enabled in settings
* - Context usage exceeds the configured threshold
* - There are enough messages to summarize
*/
shouldAutoCompact(): boolean {
// Check if auto-compact is enabled
if (!settingsState.autoCompactEnabled) {
return false;
}
// Check context usage against threshold
const threshold = settingsState.autoCompactThreshold;
if (this.contextUsage.percentage < threshold) {
return false;
}
// Check if there are enough messages to summarize
// Need at least preserveCount + 2 messages to have anything to summarize
const preserveCount = settingsState.autoCompactPreserveCount;
const minMessages = preserveCount + 2;
if (this.messagesWithTokens.length < minMessages) {
return false;
}
return true;
}
/**
* Get the number of recent messages to preserve during auto-compact
*/
getAutoCompactPreserveCount(): number {
return settingsState.autoCompactPreserveCount;
}
}
/** Singleton context manager instance */

View File

@@ -79,18 +79,22 @@ export async function generateSummary(
/**
* Determine which messages should be summarized
* Returns indices of messages to summarize (older messages) and messages to keep
* @param messages - All messages in the conversation
* @param targetFreeTokens - Not currently used (preserved for API compatibility)
* @param preserveCount - Number of recent messages to keep (defaults to PRESERVE_RECENT_MESSAGES)
*/
export function selectMessagesForSummarization(
messages: MessageNode[],
targetFreeTokens: number
targetFreeTokens: number,
preserveCount: number = PRESERVE_RECENT_MESSAGES
): { toSummarize: MessageNode[]; toKeep: MessageNode[] } {
if (messages.length <= PRESERVE_RECENT_MESSAGES) {
if (messages.length <= preserveCount) {
return { toSummarize: [], toKeep: messages };
}
// Calculate how many messages to summarize
// Keep the recent ones, summarize the rest
const cutoffIndex = Math.max(0, messages.length - PRESERVE_RECENT_MESSAGES);
const cutoffIndex = Math.max(0, messages.length - preserveCount);
// Filter out system messages from summarization (they should stay)
const toSummarize: MessageNode[] = [];

View File

@@ -9,6 +9,7 @@ export { UIState, uiState } from './ui.svelte.js';
export { ToastState, toastState } from './toast.svelte.js';
export { toolsState } from './tools.svelte.js';
export { promptsState } from './prompts.svelte.js';
export { SettingsState, settingsState } from './settings.svelte.js';
export type { Prompt } from './prompts.svelte.js';
export { VersionState, versionState } from './version.svelte.js';

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

@@ -6,9 +6,12 @@
import {
type ModelParameters,
type ChatSettings,
type AutoCompactSettings,
DEFAULT_MODEL_PARAMETERS,
DEFAULT_CHAT_SETTINGS,
PARAMETER_RANGES
DEFAULT_AUTO_COMPACT_SETTINGS,
PARAMETER_RANGES,
AUTO_COMPACT_RANGES
} from '$lib/types/settings';
import type { ModelDefaults } from './models.svelte';
@@ -30,6 +33,11 @@ export class SettingsState {
// Panel visibility
isPanelOpen = $state(false);
// Auto-compact settings
autoCompactEnabled = $state(DEFAULT_AUTO_COMPACT_SETTINGS.enabled);
autoCompactThreshold = $state(DEFAULT_AUTO_COMPACT_SETTINGS.threshold);
autoCompactPreserveCount = $state(DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount);
// Derived: Current model parameters object
modelParameters = $derived.by((): ModelParameters => ({
temperature: this.temperature,
@@ -141,6 +149,32 @@ export class SettingsState {
this.saveToStorage();
}
/**
* Toggle auto-compact enabled state
*/
toggleAutoCompact(): void {
this.autoCompactEnabled = !this.autoCompactEnabled;
this.saveToStorage();
}
/**
* Update auto-compact threshold
*/
updateAutoCompactThreshold(value: number): void {
const range = AUTO_COMPACT_RANGES.threshold;
this.autoCompactThreshold = Math.max(range.min, Math.min(range.max, value));
this.saveToStorage();
}
/**
* Update auto-compact preserve count
*/
updateAutoCompactPreserveCount(value: number): void {
const range = AUTO_COMPACT_RANGES.preserveCount;
this.autoCompactPreserveCount = Math.max(range.min, Math.min(range.max, Math.round(value)));
this.saveToStorage();
}
/**
* Load settings from localStorage
*/
@@ -151,11 +185,17 @@ export class SettingsState {
const settings: ChatSettings = JSON.parse(stored);
// Model parameters
this.useCustomParameters = settings.useCustomParameters ?? false;
this.temperature = settings.modelParameters?.temperature ?? DEFAULT_MODEL_PARAMETERS.temperature;
this.top_k = settings.modelParameters?.top_k ?? DEFAULT_MODEL_PARAMETERS.top_k;
this.top_p = settings.modelParameters?.top_p ?? DEFAULT_MODEL_PARAMETERS.top_p;
this.num_ctx = settings.modelParameters?.num_ctx ?? DEFAULT_MODEL_PARAMETERS.num_ctx;
// Auto-compact settings
this.autoCompactEnabled = settings.autoCompact?.enabled ?? DEFAULT_AUTO_COMPACT_SETTINGS.enabled;
this.autoCompactThreshold = settings.autoCompact?.threshold ?? DEFAULT_AUTO_COMPACT_SETTINGS.threshold;
this.autoCompactPreserveCount = settings.autoCompact?.preserveCount ?? DEFAULT_AUTO_COMPACT_SETTINGS.preserveCount;
} catch (error) {
console.warn('[Settings] Failed to load from localStorage:', error);
}
@@ -168,7 +208,12 @@ export class SettingsState {
try {
const settings: ChatSettings = {
useCustomParameters: this.useCustomParameters,
modelParameters: this.modelParameters
modelParameters: this.modelParameters,
autoCompact: {
enabled: this.autoCompactEnabled,
threshold: this.autoCompactThreshold,
preserveCount: this.autoCompactPreserveCount
}
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));

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

@@ -77,6 +77,37 @@ export const PARAMETER_DESCRIPTIONS: Record<keyof ModelParameters, string> = {
num_ctx: 'Context window size in tokens. Larger uses more memory.'
};
/**
* Auto-compact settings for automatic context management
*/
export interface AutoCompactSettings {
/** Whether auto-compact is enabled */
enabled: boolean;
/** Context usage threshold (percentage) to trigger auto-compact */
threshold: number;
/** Number of recent messages to preserve when compacting */
preserveCount: number;
}
/**
* Default auto-compact settings
*/
export const DEFAULT_AUTO_COMPACT_SETTINGS: AutoCompactSettings = {
enabled: false,
threshold: 70,
preserveCount: 6
};
/**
* Auto-compact parameter ranges for UI
*/
export const AUTO_COMPACT_RANGES = {
threshold: { min: 50, max: 90, step: 5 },
preserveCount: { min: 2, max: 20, step: 1 }
} as const;
/**
* Chat settings including model parameters
*/
@@ -86,6 +117,9 @@ export interface ChatSettings {
/** Custom model parameters (used when useCustomParameters is true) */
modelParameters: ModelParameters;
/** Auto-compact settings for context management */
autoCompact?: AutoCompactSettings;
}
/**
@@ -93,5 +127,6 @@ export interface ChatSettings {
*/
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
useCustomParameters: false,
modelParameters: { ...DEFAULT_MODEL_PARAMETERS }
modelParameters: { ...DEFAULT_MODEL_PARAMETERS },
autoCompact: { ...DEFAULT_AUTO_COMPACT_SETTINGS }
};

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

@@ -0,0 +1,381 @@
<script lang="ts">
/**
* Settings page
* Comprehensive settings for appearance, models, memory, and more
*/
import { modelsState, uiState, settingsState } from '$lib/stores';
import { getPrimaryModifierDisplay } from '$lib/utils';
import { PARAMETER_RANGES, PARAMETER_LABELS, PARAMETER_DESCRIPTIONS, AUTO_COMPACT_RANGES } from '$lib/types/settings';
const modifierKey = getPrimaryModifierDisplay();
// Local state for default model selection
let defaultModel = $state<string | null>(modelsState.selectedId);
// Save default model when it changes
function handleModelChange(): void {
if (defaultModel) {
modelsState.select(defaultModel);
}
}
// Get current model defaults for reset functionality
const currentModelDefaults = $derived(
modelsState.selectedId ? modelsState.getModelDefaults(modelsState.selectedId) : undefined
);
</script>
<div class="h-full overflow-y-auto bg-theme-primary p-6">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-theme-primary">Settings</h1>
<p class="mt-1 text-sm text-theme-muted">
Configure appearance, model defaults, and behavior
</p>
</div>
<!-- Appearance Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Appearance
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Dark Mode Toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Dark Mode</p>
<p class="text-xs text-theme-muted">Toggle between light and dark theme</p>
</div>
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="relative inline-flex h-6 w-11 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-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={uiState.darkMode}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<!-- System Theme Sync -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-secondary">Use System Theme</p>
<p class="text-xs text-theme-muted">Match your OS light/dark preference</p>
</div>
<button
type="button"
onclick={() => uiState.useSystemTheme()}
class="rounded-lg bg-theme-tertiary px-3 py-1.5 text-xs font-medium text-theme-secondary transition-colors hover:bg-theme-hover"
>
Sync with System
</button>
</div>
</div>
</section>
<!-- Chat Defaults Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Chat Defaults
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div>
<label for="default-model" class="text-sm font-medium text-theme-secondary">Default Model</label>
<p class="text-xs text-theme-muted mb-2">Model used for new conversations</p>
<select
id="default-model"
bind:value={defaultModel}
onchange={handleModelChange}
class="w-full rounded-lg border border-theme-subtle bg-theme-tertiary px-3 py-2 text-theme-secondary focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
{#each modelsState.chatModels as model}
<option value={model.name}>{model.name}</option>
{/each}
</select>
</div>
</div>
</section>
<!-- Model Parameters Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Model Parameters
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Use Custom Parameters Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Use Custom Parameters</p>
<p class="text-xs text-theme-muted">Override model defaults with custom values</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleCustomParameters(currentModelDefaults)}
class="relative inline-flex h-6 w-11 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-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.useCustomParameters}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.useCustomParameters}
<!-- Temperature -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="temperature" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.temperature}</label>
<span class="text-sm text-theme-muted">{settingsState.temperature.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.temperature}</p>
<input
id="temperature"
type="range"
min={PARAMETER_RANGES.temperature.min}
max={PARAMETER_RANGES.temperature.max}
step={PARAMETER_RANGES.temperature.step}
value={settingsState.temperature}
oninput={(e) => settingsState.updateParameter('temperature', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top K -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_k" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_k}</label>
<span class="text-sm text-theme-muted">{settingsState.top_k}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_k}</p>
<input
id="top_k"
type="range"
min={PARAMETER_RANGES.top_k.min}
max={PARAMETER_RANGES.top_k.max}
step={PARAMETER_RANGES.top_k.step}
value={settingsState.top_k}
oninput={(e) => settingsState.updateParameter('top_k', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Top P -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="top_p" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.top_p}</label>
<span class="text-sm text-theme-muted">{settingsState.top_p.toFixed(2)}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.top_p}</p>
<input
id="top_p"
type="range"
min={PARAMETER_RANGES.top_p.min}
max={PARAMETER_RANGES.top_p.max}
step={PARAMETER_RANGES.top_p.step}
value={settingsState.top_p}
oninput={(e) => settingsState.updateParameter('top_p', parseFloat(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Context Length -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="num_ctx" class="text-sm font-medium text-theme-secondary">{PARAMETER_LABELS.num_ctx}</label>
<span class="text-sm text-theme-muted">{settingsState.num_ctx.toLocaleString()}</span>
</div>
<p class="text-xs text-theme-muted mb-2">{PARAMETER_DESCRIPTIONS.num_ctx}</p>
<input
id="num_ctx"
type="range"
min={PARAMETER_RANGES.num_ctx.min}
max={PARAMETER_RANGES.num_ctx.max}
step={PARAMETER_RANGES.num_ctx.step}
value={settingsState.num_ctx}
oninput={(e) => settingsState.updateParameter('num_ctx', parseInt(e.currentTarget.value))}
class="w-full accent-orange-500"
/>
</div>
<!-- Reset Button -->
<div class="pt-2">
<button
type="button"
onclick={() => settingsState.resetToDefaults(currentModelDefaults)}
class="text-sm text-orange-400 hover:text-orange-300 transition-colors"
>
Reset to model defaults
</button>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Using model defaults. Enable custom parameters to adjust temperature, sampling, and context length.
</p>
{/if}
</div>
</section>
<!-- Memory Management Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
Memory Management
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
<!-- Auto-Compact Toggle -->
<div class="flex items-center justify-between pb-4 border-b border-theme">
<div>
<p class="text-sm font-medium text-theme-secondary">Auto-Compact</p>
<p class="text-xs text-theme-muted">Automatically summarize older messages when context usage is high</p>
</div>
<button
type="button"
onclick={() => settingsState.toggleAutoCompact()}
class="relative inline-flex h-6 w-11 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-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
role="switch"
aria-checked={settingsState.autoCompactEnabled}
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
{#if settingsState.autoCompactEnabled}
<!-- Threshold Slider -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="compact-threshold" class="text-sm font-medium text-theme-secondary">Context Threshold</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactThreshold}%</span>
</div>
<p class="text-xs text-theme-muted mb-2">Trigger compaction when context usage exceeds this percentage</p>
<input
id="compact-threshold"
type="range"
min={AUTO_COMPACT_RANGES.threshold.min}
max={AUTO_COMPACT_RANGES.threshold.max}
step={AUTO_COMPACT_RANGES.threshold.step}
value={settingsState.autoCompactThreshold}
oninput={(e) => settingsState.updateAutoCompactThreshold(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.threshold.min}%</span>
<span>{AUTO_COMPACT_RANGES.threshold.max}%</span>
</div>
</div>
<!-- Preserve Count -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="preserve-count" class="text-sm font-medium text-theme-secondary">Messages to Preserve</label>
<span class="text-sm text-theme-muted">{settingsState.autoCompactPreserveCount}</span>
</div>
<p class="text-xs text-theme-muted mb-2">Number of recent messages to keep intact (not summarized)</p>
<input
id="preserve-count"
type="range"
min={AUTO_COMPACT_RANGES.preserveCount.min}
max={AUTO_COMPACT_RANGES.preserveCount.max}
step={AUTO_COMPACT_RANGES.preserveCount.step}
value={settingsState.autoCompactPreserveCount}
oninput={(e) => settingsState.updateAutoCompactPreserveCount(parseInt(e.currentTarget.value))}
class="w-full accent-emerald-500"
/>
<div class="flex justify-between text-xs text-theme-muted mt-1">
<span>{AUTO_COMPACT_RANGES.preserveCount.min}</span>
<span>{AUTO_COMPACT_RANGES.preserveCount.max}</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-muted py-2">
Enable auto-compact to automatically manage context usage. When enabled, older messages
will be summarized when context usage exceeds your threshold.
</p>
{/if}
</div>
</section>
<!-- Keyboard Shortcuts Section -->
<section class="mb-8">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Keyboard Shortcuts
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Chat</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+N</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Search</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+K</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Toggle Sidebar</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">{modifierKey}+B</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">Send Message</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Enter</kbd>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-theme-secondary">New Line</span>
<kbd class="rounded bg-theme-tertiary px-2 py-1 font-mono text-xs text-theme-muted">Shift+Enter</kbd>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section>
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" 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>
About
</h2>
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="flex items-center gap-4">
<div class="rounded-lg bg-theme-tertiary p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<h3 class="font-semibold text-theme-primary">Vessel</h3>
<p class="text-sm text-theme-muted">
A modern interface for local AI with chat, tools, and memory management.
</p>
</div>
</div>
</div>
</section>
</div>
</div>

View File

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