// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
-import storybook from 'eslint-plugin-storybook';
-
-import prettier from 'eslint-config-prettier';
+import svelteConfig from './svelte.config.js';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
+import prettier from 'eslint-config-prettier';
+import perfectionist from 'eslint-plugin-perfectionist';
+import simpleImportSort from 'eslint-plugin-simple-import-sort';
+import storybook from 'eslint-plugin-storybook';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
-import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ plugins: { perfectionist, 'simple-import-sort': simpleImportSort },
rules: {
- // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
- // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
- 'no-undef': 'off',
- 'svelte/no-at-html-tags': 'off',
- // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
- 'svelte/no-navigation-without-resolve': 'off',
-
// Snippet bodies often ignore one or more of the parent's params
// (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read).
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
-
// Enforce empty line at end of file
- 'eol-last': 'error'
+ 'eol-last': 'error',
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ 'no-undef': 'off',
+
+ 'padding-line-between-statements': [
+ 'error',
+ // Blank line between function/class declarations.
+ { blankLine: 'always', next: ['function', 'class'], prev: ['function', 'class'] },
+ // Blank line around if blocks (if/else and else if stay one statement).
+ { blankLine: 'always', next: '*', prev: 'if' },
+ { blankLine: 'always', next: 'if', prev: '*' },
+ // Blank line after the last declaration in a group. Because the 'never'
+ // rules below are scoped per declaration kind, a const group and a let
+ // group get separated by a blank line, while same-kind declarations stay
+ // together.
+ { blankLine: 'always', next: '*', prev: ['const', 'let', 'var'] },
+ // No blank line between consecutive declarations of the same kind (kept
+ // last so each takes precedence over the always rule above for matching
+ // declaration pairs).
+ { blankLine: 'never', next: 'const', prev: 'const' },
+ { blankLine: 'never', next: 'let', prev: 'let' },
+ { blankLine: 'never', next: 'var', prev: 'var' },
+ // Blank line before a statement that follows another statement in the block
+ // (works for return/throw/break/continue). A blank line for a terminal
+ // statement that opens a block body can't be enforced here: Prettier removes
+ // the leading blank line of a block, so the two formatters would fight.
+ { blankLine: 'always', next: ['return', 'throw', 'break', 'continue'], prev: '*' }
+ ],
+
+ 'perfectionist/sort-objects': ['error', { type: 'natural' }],
+
+ // Alphabetical order for variable declarations and object keys
+ 'perfectionist/sort-variable-declarations': ['error', { type: 'natural' }],
+
+ // Sort imports alphabetically by module path, and sort named members within
+ // each statement. A single catch-all group keeps the list flat (no blank-line
+ // grouping); Prettier normalizes comma spacing afterwards.
+ 'simple-import-sort/imports': ['error', { groups: [['.*']] }],
+ 'svelte/no-at-html-tags': 'off',
+
+ // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
+ 'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
- projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
+ projectService: true,
svelteConfig
}
}
"dompurify": "3.4.13",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-perfectionist": "^5.10.1",
+ "eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-storybook": "10.5.6",
"eslint-plugin-svelte": "3.19.0",
"fflate": "0.8.3",
"eslint": ">=7.0.0"
}
},
+ "node_modules/eslint-plugin-perfectionist": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.10.1.tgz",
+ "integrity": "sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/utils": "^8.65.0",
+ "natural-orderby": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/project-service": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
+ "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.66.0",
+ "@typescript-eslint/types": "^8.66.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
+ "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
+ "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/types": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
+ "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
+ "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.66.0",
+ "@typescript-eslint/tsconfig-utils": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/utils": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
+ "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
+ "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-plugin-perfectionist/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/eslint-plugin-simple-import-sort": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz",
+ "integrity": "sha512-NUJO0+XFCkk+o5EsAJruTgnfMEpeWrPWeJS15UVF60GgXmqz1BJ9/3hzlvG7lkL8Bubzos5cCLptThbFfPnSMQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=5.0.0"
+ }
+ },
"node_modules/eslint-plugin-storybook": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/natural-orderby": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz",
+ "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"reset": "rm -rf .svelte-kit node_modules",
- "format": "prettier --write .",
+ "format": "eslint --fix . && prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
"@storybook/addon-docs": "10.5.6",
+ "@storybook/addon-mcp": "0.7.0",
"@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.5.6",
"@storybook/sveltekit": "10.5.6",
"dompurify": "3.4.13",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-perfectionist": "^5.10.1",
+ "eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-storybook": "10.5.6",
"eslint-plugin-svelte": "3.19.0",
"fflate": "0.8.3",
"vite-plugin-devtools-json": "0.2.1",
"vitest": "4.1.10",
"vitest-browser-svelte": "2.1.1",
- "workbox-window": "7.4.1",
- "@storybook/addon-mcp": "0.7.0"
+ "workbox-window": "7.4.1"
},
"overrides": {
"cookie": "1.1.1",
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
- testDir: 'tests/e2e',
- testMatch: ['**/*.e2e.ts'],
- timeout: 30000,
expect: {
timeout: 5000
},
- fullyParallel: true,
forbidOnly: !!process.env.CI,
- retries: process.env.CI ? 2 : 0,
- workers: process.env.CI ? 1 : undefined,
- reporter: 'line',
- use: {
- baseURL: 'http://localhost:8181',
- trace: 'on-first-retry'
- },
+ fullyParallel: true,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
+ reporter: 'line',
+ retries: process.env.CI ? 2 : 0,
+ testDir: 'tests/e2e',
+ testMatch: ['**/*.e2e.ts'],
+ timeout: 30000,
+ use: {
+ baseURL: 'http://localhost:8181',
+ trace: 'on-first-retry'
+ },
webServer: {
command: 'npm run build && npx http-server ./dist -p 8181',
port: 8181,
- timeout: 120000,
- reuseExistingServer: !process.env.CI
- }
+ reuseExistingServer: !process.env.CI,
+ timeout: 120000
+ },
+ workers: process.env.CI ? 1 : undefined
});
-import { defineConfig } from '@vite-pwa/assets-generator/config';
-import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
import { writeThemeFavicons } from './scripts/favicon-colorize';
+import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
+import { defineConfig } from '@vite-pwa/assets-generator/config';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
headLinkOptions: {
preset: '2023'
},
+ images: ['static/favicon-dark.svg'],
preset: {
- transparent: {
- sizes: [],
- favicons: [[48, 'favicon-dark.ico']],
- padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+ apple: {
+ sizes: []
},
maskable: {
sizes: []
},
- apple: {
+ transparent: {
+ favicons: [[48, 'favicon-dark.ico']],
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING,
sizes: []
}
- },
- images: ['static/favicon-dark.svg']
+ }
});
+import { writeThemeFavicons } from './scripts/favicon-colorize';
+import {
+ FAVICON_COLORS,
+ PWA_ASSET_GENERATOR,
+ PWA_GENERATOR_DEVICES,
+ THEME_COLORS
+} from './src/lib/constants/pwa';
+import { SplashOrientation } from './src/lib/enums/splash.enums';
import {
combinePresetAndAppleSplashScreens,
defineConfig,
} from '@vite-pwa/assets-generator/config';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
-import {
- THEME_COLORS,
- PWA_GENERATOR_DEVICES,
- PWA_ASSET_GENERATOR,
- FAVICON_COLORS
-} from './src/lib/constants/pwa';
-import { SplashOrientation } from './src/lib/enums/splash.enums';
-import { writeThemeFavicons } from './scripts/favicon-colorize';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
headLinkOptions: {
preset: PWA_ASSET_GENERATOR.LINK_PRESET
},
+ images: ['static/favicon.svg'],
preset: combinePresetAndAppleSplashScreens(
{
...minimal2023Preset,
}
},
{
- padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
- resizeOptions: {
- background: THEME_COLORS.BACKGROUND_LIGHT,
- fit: PWA_ASSET_GENERATOR.FIT_MODE
- },
- darkResizeOptions: {
- background: THEME_COLORS.BACKGROUND_DARK,
- fit: PWA_ASSET_GENERATOR.FIT_MODE
- },
darkImageResolver: async (imageName: string) => {
if (imageName.endsWith('favicon.svg')) {
return readFileSync(resolve('static/favicon-dark.svg'));
}
},
+ darkResizeOptions: {
+ background: THEME_COLORS.BACKGROUND_DARK,
+ fit: PWA_ASSET_GENERATOR.FIT_MODE
+ },
linkMediaOptions: {
- log: true,
addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN,
basePath: PWA_ASSET_GENERATOR.BASE_PATH,
+ log: true,
xhtml: PWA_ASSET_GENERATOR.XHTML
},
- png: {
- compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
- quality: PWA_ASSET_GENERATOR.PNG_QUALITY
- },
name: (landscape, size, dark) => {
const orientation = landscape ? SplashOrientation.LANDSCAPE : SplashOrientation.PORTRAIT;
const darkPrefix = dark ? PWA_ASSET_GENERATOR.DARK_PREFIX : '';
+
return `apple-splash-${orientation}-${darkPrefix}${size.width}x${size.height}.png`;
+ },
+ padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
+ png: {
+ compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
+ quality: PWA_ASSET_GENERATOR.PNG_QUALITY
+ },
+ resizeOptions: {
+ background: THEME_COLORS.BACKGROUND_LIGHT,
+ fit: PWA_ASSET_GENERATOR.FIT_MODE
}
},
PWA_GENERATOR_DEVICES
- ),
- images: ['static/favicon.svg']
+ )
});
const HERE = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(HERE, '..');
-
const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg');
const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static');
const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg');
const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg');
-
const CURRENT_COLOR = 'currentColor';
export interface ColorizedFavicon {
darkColor: string
): ColorizedFavicon {
return {
- light: svg.replaceAll(CURRENT_COLOR, lightColor),
- dark: svg.replaceAll(CURRENT_COLOR, darkColor)
+ dark: svg.replaceAll(CURRENT_COLOR, darkColor),
+ light: svg.replaceAll(CURRENT_COLOR, lightColor)
};
}
if (!(padding > 0) || padding >= 1) return svg;
const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
+
if (!viewBoxMatch) return svg;
const parts = viewBoxMatch[1]
.trim()
.split(/[\s,]+/)
.map(Number);
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg;
const [, , width, height] = parts;
+
if (width <= 0 || height <= 0) return svg;
const scale = 1 - padding;
const translateX = (padding * width) / 2;
const translateY = (padding * height) / 2;
-
const openTagStart = svg.search(/<svg\b/i);
+
if (openTagStart === -1) return svg;
+
const openTagEnd = svg.indexOf('>', openTagStart);
+
if (openTagEnd === -1) return svg;
+
const closeStart = svg.lastIndexOf('</svg');
+
if (closeStart === -1 || closeStart <= openTagEnd) return svg;
const openTag = svg.slice(0, openTagEnd + 1);
const inner = svg.slice(openTagEnd + 1, closeStart);
const closeTag = svg.slice(closeStart);
-
const group = `<g transform="translate(${translateX} ${translateY}) scale(${scale})">`;
+
return `${openTag}${group}${inner}</g>${closeTag}`;
}
lightColor: string,
darkColor: string,
{
- sourcePath = DEFAULT_LOGO,
- lightOutPath = DEFAULT_OUT_LIGHT,
darkOutPath = DEFAULT_OUT_DARK,
- padding = 0
+ lightOutPath = DEFAULT_OUT_LIGHT,
+ padding = 0,
+ sourcePath = DEFAULT_LOGO
}: WriteThemeFaviconsOptions = {}
): void {
const source = readFileSync(sourcePath, 'utf-8');
- const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor);
+ const { dark, light } = colorizeFaviconSvg(source, lightColor, darkColor);
+
mkdirSync(dirname(lightOutPath), { recursive: true });
writeFileSync(lightOutPath, padFaviconSvg(light, padding));
writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
* maskable-icon and apple-touch-icon are left untouched.
*/
-import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
+import sharp from 'sharp';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-
const STATIC_DIR = path.resolve(__dirname, '..', 'static');
-
const paddingPct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--padding-pct' && args[i + 1]) return parseFloat(args[i + 1]);
+
return acc;
}, 0);
-
// Scale down the source image before cropping to circle
const scalePct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--scale-pct' && args[i + 1]) return parseFloat(args[i + 1]);
+
return acc;
}, 85); // default 85% - icon fills 85% of the circular area
-
// Source for circular icons: the maskable icon (white bg, full logo)
const sourceIcon = 'maskable-icon-512x512.png';
const targetIcons = ['pwa-64x64.png', 'pwa-192x192.png', 'pwa-512x512.png'];
-
// maskable-icon and apple-touch-icon stay square
const untouchedIcons = ['maskable-icon-512x512.png', 'apple-touch-icon-180x180.png'];
if (!fs.existsSync(sourcePath)) {
console.log(`⏭️ ${sourceIcon} not found, skipping`);
+
return;
}
+
if (!fs.existsSync(targetPath)) {
console.log(`⏭️ ${targetFilename} not found, skipping`);
+
return;
}
const size = Math.max(metadata.width, metadata.height);
const radius = Math.floor((size * (1 - paddingPct / 100)) / 2);
const center = Math.floor(size / 2);
-
// Build circular mask as RGBA buffer: white opaque circle on transparent bg
const maskBuf = Buffer.alloc(size * size * 4, 0);
+
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - center;
const dy = y - center;
const dist = Math.sqrt(dx * dx + dy * dy);
+
if (dist < radius) {
const i = (y * size + x) * 4;
+
maskBuf[i] = 255;
maskBuf[i + 1] = 255;
maskBuf[i + 2] = 255;
}
const tmpMask = path.join(STATIC_DIR, '.mask-tmp.png');
+
await sharp(maskBuf, {
- raw: { width: size, height: size, channels: 4 }
+ raw: { channels: 4, height: size, width: size }
})
.png()
.toFile(tmpMask);
const circleDiameter = Math.floor(size * (1 - paddingPct / 100));
const scaledSize = Math.floor((circleDiameter * scalePct) / 100);
const offset = Math.floor((size - scaledSize) / 2);
-
const scaledBuf = await sharp(sourcePath)
.resize(scaledSize, scaledSize, {
- fit: 'cover',
- background: { r: 255, g: 255, b: 255, alpha: 1 }
+ background: { alpha: 1, b: 255, g: 255, r: 255 },
+ fit: 'cover'
})
.ensureAlpha()
.png()
.toBuffer();
-
// Step 2: Composite scaled image onto white background, then apply circular mask
const output = await sharp({
create: {
- width: size,
- height: size,
+ background: { alpha: 1, b: 255, g: 255, r: 255 },
channels: 4,
- background: { r: 255, g: 255, b: 255, alpha: 1 }
+ height: size,
+ width: size
}
})
.composite([
- { input: scaledBuf, top: offset, left: offset },
- { input: tmpMask, top: 0, left: 0, blend: 'dest-in' }
+ { input: scaledBuf, left: offset, top: offset },
+ { blend: 'dest-in', input: tmpMask, left: 0, top: 0 }
])
.png()
.toBuffer();
console.log('\nUnchanged:');
for (const icon of untouchedIcons) {
const fp = path.join(STATIC_DIR, icon);
+
console.log(` ${icon} (${fs.existsSync(fp) ? fs.statSync(fp).size + ' bytes' : 'missing'})`);
}
}
-import { writeFileSync, existsSync } from 'node:fs';
+import { BUILD_CONFIG } from '../src/lib/constants/pwa';
+import { existsSync, writeFileSync } from 'node:fs';
import { resolve } from 'path';
import type { Plugin } from 'vite';
-import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false;
*/
export function buildInfoPlugin(): Plugin {
return {
- name: 'llamacpp:build-info',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
+
processed = true;
const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000';
-
const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html');
+
if (!existsSync(indexPath)) return;
const buildJsonPath = resolve(outDir, 'build.json');
+
writeFileSync(buildJsonPath, JSON.stringify({ version: buildNumber }), 'utf-8');
console.log(`Created build.json (version: ${buildNumber})`);
} catch (error) {
console.error('Failed to write build.json:', error);
}
}, 100);
- }
+ },
+ name: 'llamacpp:build-info'
};
}
import type { Plugin } from 'vite';
const __dirname = dirname(fileURLToPath(import.meta.url));
-
const VENDORS_DIR = resolve(__dirname, '../src/lib/vendors');
const VIRTUAL_ID = 'virtual:nerdamer';
const RESOLVED_ID = '\0' + VIRTUAL_ID;
let bundled: string | null = null;
return {
- name: 'llamacpp:nerdamer',
- resolveId(id) {
- return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
- },
async load(id) {
if (id !== RESOLVED_ID) return undefined;
+
if (bundled === null) {
const result = await build({
- entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')],
- bundle: true,
- minify: true,
- format: 'iife',
- globalName: 'nerdamer',
alias: {
'big-integer': resolve(VENDORS_DIR, 'big-integer/BigInteger.js'),
'decimal.js': resolve(VENDORS_DIR, 'decimal.js/decimal.js')
},
- write: false,
- logLevel: 'silent'
+ bundle: true,
+ entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')],
+ format: 'iife',
+ globalName: 'nerdamer',
+ logLevel: 'silent',
+ minify: true,
+ write: false
});
+
bundled = result.outputFiles[0].text;
}
+
return `export default ${JSON.stringify(bundled)};`;
+ },
+ name: 'llamacpp:nerdamer',
+ resolveId(id) {
+ return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
}
};
}
-import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { BUILD_CONFIG } from '../src/lib/constants/pwa';
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'path';
import type { Plugin } from 'vite';
-import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false;
if (!existsSync(path)) {
return;
}
+
const text = readFileSync(path, 'utf-8');
+
let out = text;
+
for (const [from, to] of pairs) {
out = out.split(from).join(to);
}
+
if (out !== text) {
writeFileSync(path, out, 'utf-8');
}
*/
export function relativizeBasePlugin(): Plugin {
return {
- name: 'llamacpp:relativize-base',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
+
processed = true;
const outDir = resolve(OUTPUT_DIR);
console.error('Failed to relativize base refs:', error);
}
}, 100);
- }
+ },
+ name: 'llamacpp:relativize-base'
};
}
-import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
-import { resolve } from 'path';
-import type { Plugin } from 'vite';
-import { TAB, NEWLINE } from '../src/lib/constants/code';
+import { NEWLINE, TAB } from '../src/lib/constants/code';
import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa';
-import type { SplashDimensions } from '../src/lib/types';
import { SplashOrientation } from '../src/lib/enums/splash.enums';
+import type { SplashDimensions } from '../src/lib/types';
+import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'path';
+import type { Plugin } from 'vite';
let processed = false;
*/
export function generateSplashScreenLinks(outDir: string): string[] {
const files = readdirSync(outDir).filter((f) => f.match(REGEX_PATTERNS.SPLASH_FILE));
+
if (files.length === 0) return [];
const dimMap = new Map<string, SplashDimensions>();
+
for (const [dims, spec] of Object.entries(APPLE_DEVICES)) {
const [w, h] = dims.split('x').map(Number);
+
// logical-point dimensions
- dimMap.set(`${w}x${h}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr });
- dimMap.set(`${h}x${w}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr });
+ dimMap.set(`${w}x${h}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
+ dimMap.set(`${h}x${w}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
// pixel dimensions (used by actual generated splash files)
dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, {
- deviceW: spec.width,
deviceH: spec.height,
+ deviceW: spec.width,
dpr: spec.dpr
});
dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, {
- deviceW: spec.width,
deviceH: spec.height,
+ deviceW: spec.width,
dpr: spec.dpr
});
}
for (const file of files) {
const match = file.match(REGEX_PATTERNS.SPLASH_FILE);
+
if (!match) continue;
+
const orientation = match[1] as SplashOrientation;
const isDark = !!match[2];
const pixelW = parseInt(match[3]);
const pixelH = parseInt(match[4]);
-
const key = `${pixelW}x${pixelH}`;
const spec = dimMap.get(key);
+
if (!spec) {
console.warn(`Unknown splash screen dimensions: ${key} (${file})`);
+
continue;
}
- const { deviceW, deviceH, dpr } = spec;
+ const { deviceH, deviceW, dpr } = spec;
const media = `screen and (device-width: ${deviceW}px) and (device-height: ${deviceH}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})`;
const href = `./${file}`;
export function splashScreenPlugin(): Plugin {
return {
- name: 'llamacpp:splash-screen',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
+
processed = true;
const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html');
+
if (!existsSync(indexPath)) return;
let content = readFileSync(indexPath, 'utf-8');
// The @vite-pwa/assets-generator generates apple-splash-*.png files;
// this scans them and creates the <link> tags SvelteKit needs.
const splashLinks = generateSplashScreenLinks(outDir);
+
if (splashLinks.length > 0) {
console.log(`Generated ${splashLinks.length} apple-splash link tags`);
const splashHtml = splashLinks.map((l) => TAB + TAB + l).join(NEWLINE);
+
content = content.replace(
REGEX_PATTERNS.HEAD_CLOSE,
splashHtml + NEWLINE + TAB + TAB + '</head>'
console.error('Failed to process build output:', error);
}
}, 100);
- }
+ },
+ name: 'llamacpp:splash-screen'
};
}
import 'vite-plugin-pwa/pwa-assets';
import 'vite-plugin-pwa/svelte';
-
+import { ModelModality, ServerModelStatus, ServerRole } from '$lib/enums';
// Import chat types from dedicated module
-
import type {
// API types
ApiChatCompletionRequest,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
- ApiChatMessageData,
ApiChatMessageContentPart,
+ ApiChatMessageData,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
+ ApiModelListResponse,
ApiModelLoadStage,
- ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseEvent,
- ApiModelListResponse,
+ ApiModelsSseProgress,
ApiProcessingState,
ApiRouterModelMeta,
+ ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
- ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
+ ChatMessagePromptProgress,
+ ChatMessageSiblingInfo,
+ ChatMessageTimings,
ChatMessageType,
ChatRole,
ChatUploadedFile,
- ChatMessageSiblingInfo,
- ChatMessagePromptProgress,
- ChatMessageTimings,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
- DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
- DatabaseMessageExtraTextFile,
- DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
+ DatabaseMessageExtraPdfFile,
+ DatabaseMessageExtraTextFile,
+ DatabaseMessageExtraVideoFile,
ExportedConversation,
ExportedConversations,
+ ModelLoadProgress,
// Model types
ModelModalities,
ModelOption,
- ModelLoadProgress,
// Settings types
SettingsChatServiceOptions,
+ SettingsConfigType,
SettingsConfigValue,
- SettingsFieldConfig,
- SettingsConfigType
+ SettingsFieldConfig
} from '$lib/types';
-import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums';
-
declare global {
// namespace App {
// interface Error {}
<script lang="ts">
- import { Button, type ButtonVariant, type ButtonSize } from '$lib/components/ui/button';
+ import { Button, type ButtonSize, type ButtonVariant } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
- import type { Component } from 'svelte';
import { TooltipSide } from '$lib/enums';
+ import type { Component } from 'svelte';
interface Props {
ariaLabel?: string;
}
let {
- icon,
- tooltip,
- variant = 'ghost',
- href = '',
- size = 'sm',
+ ariaLabel,
class: className = '',
disabled = false,
+ href = '',
+ icon,
iconSize = 'h-3 w-3',
- tooltipSide = TooltipSide.TOP,
- stopPropagationOnClick = false,
onclick,
- ariaLabel
+ size = 'sm',
+ stopPropagationOnClick = false,
+ tooltip,
+ tooltipSide = TooltipSide.TOP,
+ variant = 'ghost'
}: Props = $props();
let innerWidth = $state(0);
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import ActionIcon from './ActionIcon.svelte';
import { Copy } from '@lucide/svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { copyToClipboard } from '$lib/utils';
- import ActionIcon from './ActionIcon.svelte';
export let ariaLabel: string = 'Copy to clipboard';
export let canCopy: boolean = true;
class?: string;
}
- let { modalities, class: className = '' }: Props = $props();
+ let { class: className = '', modalities }: Props = $props();
</script>
{#each modalities as modality (modality)}
}
let {
- class: className = '',
- style = '',
+ activeModelId,
attachments = [],
- readonly = false,
- onFileRemove,
- uploadedFiles = $bindable([]),
+ class: className = '',
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
- activeModelId
+ onFileRemove,
+ readonly = false,
+ style = '',
+ uploadedFiles = $bindable([])
}: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state();
let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false);
- let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
+ let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
import {
ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource,
- ChatAttachmentsListItemThumbnailImage,
- ChatAttachmentsListItemThumbnailFile
+ ChatAttachmentsListItemThumbnailFile,
+ ChatAttachmentsListItemThumbnailImage
} from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
import type {
return {
id,
resource: {
- uri: extra.uri,
name: extra.name,
+ serverName: extra.serverName,
title: extra.name,
- serverName: extra.serverName
+ uri: extra.uri
}
};
}
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
- type: AttachmentType.MCP_PROMPT as const,
+ arguments: item.uploadedFile.mcpPrompt.arguments,
+ content: item.textContent ?? '',
name: item.name,
- serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
- content: item.textContent ?? '',
- arguments: item.uploadedFile.mcpPrompt.arguments
+ serverName: item.uploadedFile.mcpPrompt.serverName,
+ type: AttachmentType.MCP_PROMPT as const
}
: null}
{#if mcpPrompt}
<script lang="ts">
- import { ChatMessageMcpPromptContent, ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
- import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+ import { ActionIcon, ChatMessageMcpPromptContent } from '$lib/components/app';
import { McpPromptVariant } from '$lib/enums';
+ import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
<script lang="ts">
- import { Loader2, AlertCircle } from '@lucide/svelte';
+ import { AlertCircle, Loader2 } from '@lucide/svelte';
+ import { X } from '@lucide/svelte';
+ import { ActionIcon } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { ActionIcon } from '$lib/components/app';
- import { X } from '@lucide/svelte';
- import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
+ import { getResourceDisplayName, getResourceIcon } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
+
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
<script lang="ts">
+ import { Music, Video, X } from '@lucide/svelte';
+ import { ActionIcon } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { X, Music, Video } from '@lucide/svelte';
+ import { AttachmentType } from '$lib/enums';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
- isPdfFile,
isAudioFile,
- isVideoFile,
- isTextFile
+ isPdfFile,
+ isTextFile,
+ isVideoFile
} from '$lib/utils';
- import { ActionIcon } from '$lib/components/app';
- import { AttachmentType } from '$lib/enums';
interface Props {
attachment?: DatabaseMessageExtra;
attachment,
class: className = '',
id,
+ name,
onclick,
onRemove,
- name,
readonly = false,
size,
textContent,
<script lang="ts">
- import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
+ import { ActionIcon } from '$lib/components/app';
interface Props {
class?: string;
height = 'h-16',
id,
imageClass = '',
+ name,
onclick,
onRemove,
- name,
preview,
readonly = false,
width = 'w-auto'
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
- isVideoFile,
isImageFile,
isMcpPrompt,
isMcpResource,
isPdfFile,
- isTextFile
+ isTextFile,
+ isVideoFile
} from '$lib/utils';
interface PreviewItem {
}
let {
- uploadedFiles = [],
- attachments = [],
activeModelId,
+ attachments = [],
class: className = '',
- previewFocusIndex = 0
+ previewFocusIndex = 0,
+ uploadedFiles = []
}: Props = $props();
let allItems = $derived(
- getAttachmentDisplayItems({ uploadedFiles, attachments })
+ getAttachmentDisplayItems({ attachments, uploadedFiles })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map(
(item): PreviewItem => ({
...item,
- isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile),
+ isImage: isImageFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
$effect(() => {
const index = currentIndex;
+
setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
- thumbnail?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
+ thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 0);
});
<script lang="ts">
- import type { ChatAttachmentDisplayItem } from '$lib/types';
- import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
- import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
- import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
- import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
+ import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
+ import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
+ import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
+ import { FileIcon, FileText, Image, Music, Video } from '@lucide/svelte';
+ import type { ChatAttachmentDisplayItem } from '$lib/types';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
}
let {
+ activeModelId,
+ audioSrc,
currentItem,
- isImage,
+ displayPreview,
+ displayTextContent,
+ hasVisionModality,
isAudio,
- isVideo,
+ isImage,
isPdf,
isText,
- displayPreview,
- displayTextContent,
- audioSrc,
- videoSrc,
+ isVideo,
language,
- hasVisionModality,
- activeModelId
+ videoSrc
}: Props = $props();
let IconComponent = $derived(
audioSrc: string | null;
}
- let { currentItem, audioSrc }: Props = $props();
+ let { audioSrc, currentItem }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<script lang="ts">
+ import { Eye, FileText, Info } from '@lucide/svelte';
+ import { SyntaxHighlightedCode } from '$lib/components/app';
+ import * as Alert from '$lib/components/ui/alert';
+ import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { PdfViewMode } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types';
- import { FileText, Eye, Info } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import * as Alert from '$lib/components/ui/alert';
- import { SyntaxHighlightedCode } from '$lib/components/app';
import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
- import { PdfViewMode } from '$lib/enums';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
activeModelId?: string;
}
- let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props =
+ let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: Props =
$props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
currentItem.attachment.images.length > 0
) {
pdfImages = currentItem.attachment.images;
+
return;
}
const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
+
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
+
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
show: boolean;
}
- let { onPrev, onNext, show }: Props = $props();
+ let { onNext, onPrev, show }: Props = $props();
</script>
{#if show}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Music, Video, FileText } from '@lucide/svelte';
+ import { FileText, Music, Video } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface PreviewItem {
id: string;
onNavigate: (index: number) => void;
}
- let { items, currentIndex, onNavigate }: Props = $props();
+ let { currentIndex, items, onNavigate }: Props = $props();
function getFileExtension(name: string): string {
const parts = name.split('.');
+
if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? '';
}
+
return '';
}
</script>
<script lang="ts">
+ import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import {
ChatAttachmentsList,
ChatFormActions,
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
- INPUT_CLASSES,
- SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
- PROMPT_CONTENT_SEPARATOR
+ INPUT_CLASSES,
+ PROMPT_CONTENT_SEPARATOR,
+ SETTING_CONFIG_DEFAULT
} from '$lib/constants';
import {
ContentPartType,
MimeTypeText,
SpecialFileType
} from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
- import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
- import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
+ import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
import {
- conversationsStore,
- activeMessages,
activeConversation,
+ activeMessages,
+ conversationsStore,
pendingCwd
} from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
+ import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
import type {
FileMentionEntry,
GetPromptResult,
parseClipboardContent,
uuid
} from '$lib/utils';
- import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
AudioRecorder,
convertToWav,
class: className = '',
disabled = false,
isLoading = false,
- placeholder = 'Type a message...',
- showMcpPromptButton = false,
- showAddButton = true,
- showModelSelector = true,
- uploadedFiles = $bindable([]),
- value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
- onValueChange
+ onValueChange,
+ placeholder = 'Type a message...',
+ showAddButton = true,
+ showMcpPromptButton = false,
+ showModelSelector = true,
+ uploadedFiles = $bindable([]),
+ value = $bindable('')
}: Props = $props();
// Component References
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
const pickers = useChatFormPickers({
- getValue: () => value,
- setValue: (v) => {
- value = v;
- onValueChange?.(v);
- },
- getCaretOffset: () => inputRef?.getCaretOffset(),
- setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
- getShowModelSelector: () => showModelSelector,
- hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
- hasCwdTools: () => toolsStore.hasEnabledCwdTools,
+ getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
+ getPickersRef: () => pickersRef,
getServerHome: () => toolsStore.serverHome ?? null,
+ getShowModelSelector: () => showModelSelector,
+ getValue: () => value,
+ hasCwdTools: () => toolsStore.hasEnabledCwdTools,
+ hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
- getPickersRef: () => pickersRef
+ setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
+ setValue: (v) => {
+ value = v;
+ onValueChange?.(v);
+ }
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
+
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
+
await conversationsStore.setCwd(newDir);
+
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null);
}
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
+
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
}
const selectedId = selectedModelId();
+
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
+
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
+
if (model) return model.model;
}
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
+
if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) {
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
+
return false;
}
+
return true;
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
+
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
+
return;
}
type: MimeTypeText.PLAIN
})
);
+
onFilesAdd?.(attachmentFiles);
}
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
- id: uuid(),
- name: att.name,
- size: att.content.length,
- type: SpecialFileType.MCP_PROMPT,
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
+ id: uuid(),
isLoading: false,
- textContent: att.content,
mcpPrompt: {
- serverName: att.serverName,
+ arguments: att.arguments,
promptName: att.promptName,
- arguments: att.arguments
- }
+ serverName: att.serverName
+ },
+ name: att.name,
+ size: att.content.length,
+ textContent: att.content,
+ type: SpecialFileType.MCP_PROMPT
}));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
- id: placeholderId,
- name: promptName,
- size: INITIAL_FILE_SIZE,
- type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'),
+ id: placeholderId,
isLoading: true,
mcpPrompt: {
- serverName: promptInfo.serverName,
+ arguments: args ? { ...args } : undefined,
promptName: promptInfo.name,
- arguments: args ? { ...args } : undefined
- }
+ serverName: promptInfo.serverName
+ },
+ name: promptName,
+ size: INITIAL_FILE_SIZE,
+ type: SpecialFileType.MCP_PROMPT
};
uploadedFiles = [...uploadedFiles, placeholder];
f.id === placeholderId
? {
...f,
- isLoading: false,
- textContent: promptText,
- size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
- })
+ }),
+ isLoading: false,
+ size: promptText.length,
+ textContent: promptText
}
: f
);
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
+
if (!token) return;
const built = buildMentionInsertion(entry, value, token);
+
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
+
return;
}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
- onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
+ onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
disabled?: boolean;
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
+ import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
+ import {
+ ChatFormActionAddMcpServersSubmenu,
+ ChatFormActionAddReasoningSubmenu,
+ ChatFormActionAddToolsSubmenu
+ } from '$lib/components/app';
+ import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
- import {
- ChatFormActionAddToolsSubmenu,
- ChatFormActionAddMcpServersSubmenu,
- ChatFormActionAddReasoningSubmenu
- } from '$lib/components/app';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
class: className = '',
disabled = false,
hasAudioModality = false,
- hasVideoModality = false,
- hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
+ hasVideoModality = false,
+ hasVisionModality = false,
onFileUpload,
- onSystemPromptClick,
onMcpPromptClick,
+ onMcpResourcesClick,
onMcpSettingsClick,
- onMcpResourcesClick
+ onSystemPromptClick
}: Props = $props();
let dropdownOpen = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
- hasVisionModality,
hasAudioModality,
- hasVideoModality,
hasMcpPromptsSupport,
- hasMcpResourcesSupport
+ hasMcpResourcesSupport,
+ hasVideoModality,
+ hasVisionModality
}),
- () => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
+ () => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => {
dropdownOpen = false;
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Settings, Plus } from '@lucide/svelte';
- import { Switch } from '$lib/components/ui/switch';
+ import { Plus, Settings } from '@lucide/svelte';
+ import { goto } from '$app/navigation';
+ import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app';
+ import { Switch } from '$lib/components/ui/switch';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { ROUTES } from '$lib/constants/routes';
+ import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
- import { goto } from '$app/navigation';
- import { ROUTES } from '$lib/constants/routes';
interface Props {
onMcpSettingsClick?: () => void;
let hasMcpServers = $derived(mcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
+
if (!query) return mcpServers;
+
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
+
return name.includes(query) || url.includes(query);
});
});
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
+ import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
const reasoning = useReasoningMenu();
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import type { Snippet } from 'svelte';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import * as Sheet from '$lib/components/ui/sheet';
- import * as Collapsible from '$lib/components/ui/collapsible';
- import { File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
- import { Switch } from '$lib/components/ui/switch';
- import { Checkbox } from '$lib/components/ui/checkbox';
- import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
- import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
- import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
- import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
- import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { McpLogo } from '$lib/components/app';
+ import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import {
- PencilRuler,
+ Check,
ChevronDown,
ChevronRight,
Lightbulb,
LightbulbOff,
- Check
+ PencilRuler
} from '@lucide/svelte';
+ import { McpLogo } from '$lib/components/app';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import * as Collapsible from '$lib/components/ui/collapsible';
+ import * as Sheet from '$lib/components/ui/sheet';
+ import { Switch } from '$lib/components/ui/switch';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
+ import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
+ import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
+ import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
+ import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { Snippet } from 'svelte';
interface Props {
class?: string;
class: className = '',
disabled = false,
hasAudioModality = false,
- hasVisionModality = false,
- hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
+ hasVideoModality = false,
+ hasVisionModality = false,
onFileUpload,
- onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick,
+ onSystemPromptClick,
trigger
}: Props = $props();
const attachmentMenu = useAttachmentMenu(
() => ({
- hasVisionModality,
hasAudioModality,
- hasVideoModality,
hasMcpPromptsSupport,
- hasMcpResourcesSupport
+ hasMcpResourcesSupport,
+ hasVideoModality,
+ hasVisionModality
}),
- () => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
+ () => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => {
sheetOpen = false;
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
+ import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { toolsStore } from '$lib/stores/tools.svelte';
import { CLI_FLAGS } from '$lib/constants';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
<script lang="ts">
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
- import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
interface Props {
disabled?: boolean;
let {
disabled = false,
hasAudioModality = false,
- hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
+ hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
<script lang="ts">
+ import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
+ import { activeMessages } from '$lib/stores/conversations.svelte';
import {
- modelsStore,
modelOptions,
+ modelsStore,
selectedModelId,
selectedModelName
} from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
- import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { isMobile } from '$lib/stores/viewport.svelte';
- import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
disabled?: boolean;
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
+ hasModelSelected = $bindable(false),
hasVideoModality = $bindable(false),
hasVisionModality = $bindable(false),
- hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true),
submitTooltip = $bindable(''),
useGlobalSelection = false
let selectorModel = $derived.by(() => {
const storeModel = selectedModelName();
+
if (storeModel && storeModel !== conversationModel) {
return storeModel;
}
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
+
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
) {
lastSyncedConversationModel = null;
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
+
if (first) modelsStore.selectModelById(first.id);
}
});
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
class?: string;
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Square, SkipForward } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import { ChatService } from '$lib/services';
+ import { SkipForward, Square } from '@lucide/svelte';
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
import {
- ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
+ ChatFormActionsAdd,
ChatFormActionSubmit,
ChatFormContextGauge
} from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { ROUTES } from '$lib/constants/routes';
import { FileTypeCategory, MessageRole } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
+ import { ChatService } from '$lib/services';
import {
activeProcessingState,
isChatStreaming,
isLoading as chatIsLoading
} from '$lib/stores/chat.svelte';
+ import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { config } from '$lib/stores/settings.svelte';
import { getFileTypeCategory } from '$lib/utils';
- import { goto } from '$app/navigation';
- import { page } from '$app/state';
- import { ROUTES } from '$lib/constants/routes';
interface Props {
canSend?: boolean;
isLoading = false,
isReasoning = false,
isRecording = false,
- showAddButton = true,
- showModelSelector = true,
- uploadedFiles = [],
onFileUpload,
+ onMcpPromptClick,
+ onMcpResourcesClick,
onMicClick,
onStop,
onSystemPromptClick,
- onMcpPromptClick,
- onMcpResourcesClick
+ showAddButton = true,
+ showModelSelector = true,
+ uploadedFiles = []
}: Props = $props();
let currentConfig = $derived(config());
if (!page.params.id) return false;
const messages = activeMessages() as DatabaseMessage[];
+
let totalHistoricalTokens = 0;
+
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT) continue;
+
const timings = m.timings;
+
if (!timings) continue;
+
const agenticLlm = timings.agentic?.llm;
+
if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
} else {
totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
}
}
+
if (totalHistoricalTokens > 0) return true;
if (!chatIsLoading() && !isChatStreaming()) return false;
const processingState = activeProcessingState();
+
if (!processingState) return false;
+
const livePromptTokens = Math.max(
processingState.promptTokens ?? 0,
processingState.promptProgress?.processed ?? 0
);
const liveOutputTokens = processingState.outputTokensUsed ?? 0;
+
return livePromptTokens > 0 || liveOutputTokens > 0;
});
</script>
<script lang="ts">
- import { onDestroy, onMount, untrack } from 'svelte';
- import { mode } from 'mode-watcher';
- import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
- import githubLightCss from 'highlight.js/styles/github.css?inline';
- import { isMobile } from '$lib/stores/viewport.svelte';
- import { ColorMode } from '$lib/enums';
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
+ import { ColorMode } from '$lib/enums';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
import {
badgeAwareWordJump,
buildFragment,
SourceHistory,
stripBlockBoundaryLineBreaks,
syncCodeBlockHatches,
- tokenizeContent,
- textOffsetToRange
+ textOffsetToRange,
+ tokenizeContent
} from '$lib/utils';
- import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
+ import githubLightCss from 'highlight.js/styles/github.css?inline';
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import { mode } from 'mode-watcher';
+ import { onDestroy, onMount, untrack } from 'svelte';
interface Props {
class?: string;
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
if (!rootElement) return;
+
const source = serialized ?? serializeContent(rootElement);
+
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
}
*/
function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? '';
+
if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment);
+
if (!open) return false;
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
-
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
-
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template');
+
tpl.innerHTML = html;
el.replaceChildren(
document.createTextNode(trailing + '```')
);
highlightedSegments.set(el, segment);
+
return true;
}
if (!rootElement) return;
const range = safeRange();
+
if (!range) return;
let node: Node | null = range.startContainer;
+
if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null;
}
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const caret = rangeToTextOffset(rootElement, range);
+
if (highlightCodeBlockElement(node)) {
restoreCaret(caret);
}
+
return;
}
+
node = node.parentNode;
}
}
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
const style = document.createElement('style');
+
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
if (!rootElement) return null;
const selection = window.getSelection();
+
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection();
+
if (!selection) return;
if (extend && selection.anchorNode) {
target.startContainer,
target.startOffset
);
+
return;
}
function resizeHeight() {
if (!rootElement) return;
+
rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`;
}
function recordHistory(newGroup: boolean) {
if (!rootElement) return;
+
history.push(
- { value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
+ { caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
Date.now(),
newGroup
);
// lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange());
+
if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret);
} else {
const source = serializeContent(rootElement);
+
let end = caret;
// the caret must end up after the inserted \n; some browsers
// \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element.
let last = rootElement.lastChild;
+
while (last && last.nodeName === 'BR') last = last.previousSibling;
+
if (
end === source.length &&
source.endsWith('\n') &&
syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement);
+
syncEmptyState(serialized);
+
if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits
// completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized);
+
if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens);
// block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement);
+
if (reserialized !== serialized) {
lastEmittedValue = reserialized;
value = reserialized;
if (!rootElement) return;
const range = safeRange();
+
if (!range) return;
if (!range.collapsed) {
// a break at the very end of a code block exits the block (the
// new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null;
+
if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode;
+
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const tail = document.createRange();
+
tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length);
+
if (tail.toString().length === 0) exitBlock = node;
+
break;
}
+
node = node.parentNode;
}
}
exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
+
if (offset === 0) {
text.before(nl);
} else if (offset === text.length) {
const selection = window.getSelection();
const after = document.createRange();
+
after.setStartAfter(nl);
after.collapse(true);
selection?.removeAllRanges();
if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild;
+
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
const range = safeRange();
+
if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first
if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range);
+
if (key === 'ArrowLeft') {
if (caret !== 0) return false;
} else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
+
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
+
return true;
}
if (!rootElement) return;
const first = rootElement.firstChild;
+
if (first?.nodeName !== 'BR') return;
const second = first.nextSibling;
+
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
const range = safeRange();
const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0;
+
if (!onHatch) {
first.remove();
}
*/
function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
+
if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
if (isUndo || isRedo) {
event.preventDefault();
const current = {
- value: lastEmittedValue,
- caret: rangeToTextOffset(rootElement, safeRange())
+ caret: rangeToTextOffset(rootElement, safeRange()),
+ value: lastEmittedValue
};
const entry = isUndo ? history.undo(current) : history.redo(current);
+
if (entry) applyHistoryEntry(entry);
+
return;
}
}
// stuck on the old line (see insertLineBreak).
event.preventDefault();
insertLineBreak();
+
return;
}
// re-tokenize/re-highlight follows.
event.preventDefault();
document.execCommand('insertLineBreak');
+
return;
}
) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault();
+
return;
}
}
if (target !== null) {
event.preventDefault();
restoreCaret(target, event.shiftKey);
+
return;
}
}
// change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return;
+
renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value;
value = entry.value;
*/
function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain');
+
if (pasted && pasted.length > 0) {
event.preventDefault();
// element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace.
const range = safeRange();
+
if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range));
}
// consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) {
onPaste?.(event);
+
if (!event.defaultPrevented) {
handlePasteEvent(event);
}
if (!rootElement) return null;
const range = safeRange();
+
if (!range || range.collapsed) return null;
const startRange = range.cloneRange();
+
startRange.collapse(true);
const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range);
- return { text: source.slice(start, end), range };
+ return { range, text: source.slice(start, end) };
}
function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice();
+
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice();
+
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
resizeHeight();
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
+
if (!isMobile.current) {
rootElement?.focus({ preventScroll: true });
}
// browser already owns the right shape.
$effect(() => {
const incoming = value ?? '';
+
if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
export function getCaretOffset(): number {
if (!rootElement) return 0;
+
return rangeToTextOffset(rootElement, safeRange());
}
if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true });
}
+
restoreCaret(offset);
}
export function focus() {
if (isMobile.current) return;
+
rootElement?.focus({ preventScroll: true });
}
<script lang="ts">
- import { untrack } from 'svelte';
- import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
- import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
- import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDial from './ContextGaugeDial.svelte';
+ import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+ import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import {
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from '$lib/stores/context-gauge-popup.svelte';
+ import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
+ import { untrack } from 'svelte';
const gauge = useContextGauge();
$effect(() => {
const conv = activeConversation();
+
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
});
$effect(() => {
const conv = activeConversation();
const messages = activeMessages() as DatabaseMessage[];
+
if (!conv) return;
+
if (isLoading() || isChatStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
+
return;
}
subtitle?: string;
}
- let { label, value, subtitle }: Props = $props();
+ let { label, subtitle, value }: Props = $props();
</script>
<div class="grid gap-1.5">
<script lang="ts">
+ import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
- import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
interface Props {
currentRead: number;
}
let {
- currentRead,
- currentFresh,
+ averageTokensPerSecond,
+ cumulativeCacheTotal,
+ cumulativeOutput,
+ cumulativeRead,
currentCache,
+ currentFresh,
currentOutput,
+ currentRead,
kvTotal,
- cumulativeRead,
- cumulativeOutput,
- cumulativeCacheTotal,
- averageTokensPerSecond,
transientDetails
}: Props = $props();
size?: 'sm' | 'md';
}
- let { percent, level, size = 'sm' }: Props = $props();
+ let { level, percent, size = 'sm' }: Props = $props();
const RADIUS = 11;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
onLoad: () => void;
}
- let { modelId, isLoading, onLoad }: Props = $props();
+ let { isLoading, modelId, onLoad }: Props = $props();
</script>
{#if modelId !== null && !isLoading}
<script lang="ts">
- import { formatParameters } from '$lib/utils/formatters';
- import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+ import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
- import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
+ import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import {
- gaugePopup,
gaugeCardEnter,
gaugeCardLeave,
+ gaugePopup,
gaugePopupClose
} from '$lib/stores/context-gauge-popup.svelte';
+ import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
+
if (!(target instanceof Node)) return;
+
if (cardEl?.contains(target)) return;
+
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
+
gaugePopupClose();
};
document.addEventListener('pointerdown', onPointerDown, true);
+
return () => document.removeEventListener('pointerdown', onPointerDown, true);
});
export function colorLevelFromPercent(percent: number | null): ColorLevel {
if (percent === null) return 'neutral';
+
if (percent >= CRITICAL_THRESHOLD) return 'critical';
+
if (percent >= WARNING_THRESHOLD) return 'warning';
+
return 'ok';
}
<script lang="ts">
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import {
- mcpResourceAttachments,
- mcpHasResourceAttachments
- } from '$lib/stores/mcp-resources.svelte';
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import {
+ mcpHasResourceAttachments,
+ mcpResourceAttachments
+ } from '$lib/stores/mcp-resources.svelte';
interface Props {
class?: string;
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
- import { MODEL_SELECTOR_ICON } from '$lib/constants';
- import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
- import { ChatFormCommandAction } from '$lib/enums';
- import type { ChatFormCommand } from '$lib/types';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
+ import { MODEL_SELECTOR_ICON } from '$lib/constants';
+ import { ChatFormCommandAction } from '$lib/enums';
+ import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+ import type { ChatFormCommand } from '$lib/types';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
onSelect: (command: ChatFormCommand) => void;
}
- let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
+ let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
- [ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen,
- [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
+ [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
+ [ChatFormCommandAction.PROMPT]: Sparkles
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
+
if (n === 0) return -1;
+
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
+
if (!filteredCommands[idx].disabled) return idx;
}
+
return -1;
}
const nav = usePickerNavigation({
- isOpen: () => isOpen,
count: () => filteredCommands.length,
- step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
+ isOpen: () => isOpen,
onClose: () => onClose(),
- onSelect: (index) => handleSelect(filteredCommands[index])
+ onSelect: (index) => handleSelect(filteredCommands[index]),
+ step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir))
});
$effect(() => {
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
+
return;
}
+
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
+
onSelect(command);
onClose();
}
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
- import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
- import { isMobile } from '$lib/stores/viewport.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
+ import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
- import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
- import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
- import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
- import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
- import type { FileMentionEntry } from '$lib/types';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
+ import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
+ import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
+ import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import type { FileMentionEntry } from '$lib/types';
+ import { abbreviateHome, type GlobEntryResult, runGlobSearchWithChildren } from '$lib/utils';
/**
* Floating file/folder mention picker. The chat input is the search
let {
class: className = '',
- isOpen,
- query,
customAnchor = null,
- scopePath = null,
+ isOpen,
onClose,
+ onOpened,
onSelect,
- onOpened
+ query,
+ scopePath = null
}: Props = $props();
const nav = usePickerNavigation({
- isOpen: () => isOpen,
count: () => displayedItems.length,
+ isOpen: () => isOpen,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
+
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
});
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
- debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
+ debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
- { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+ { descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
);
+
if (!isCurrent()) return;
+
if (res.error) {
searchResults = [];
searchError = res.error;
+
return;
}
+
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
- path: e.path,
name: e.name,
+ path: e.path,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
+
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
+
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
+
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
+
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
$effect(() => {
if (typeof window === 'undefined') return;
+
void toolsStore.resolveServerHome();
});
$effect(() => {
const q = (query ?? '').trim();
+
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
+
return;
}
+
search.setLoading(true);
search.run(q);
});
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
+
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
+
return true;
}
<script lang="ts">
- import type { Snippet } from 'svelte';
- import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { MCPServerSettingsEntry } from '$lib/types';
+ import type { Snippet } from 'svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
subtitle?: Snippet;
}
- let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
+ let { description, server, serverLabel, subtitle, title, titleExtra }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
<script lang="ts" generics="T">
- import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
- import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
+ import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
+ import type { Snippet } from 'svelte';
interface Props {
items: T[];
}
let {
- items,
- isLoading,
- selectedIndex,
- searchQuery = $bindable(),
- showSearchInput,
- searchPlaceholder = 'Search...',
- emptyMessage,
autofocus = false,
+ emptyMessage,
+ footer,
inputRef = $bindable(null),
- onSearchClose,
- itemKey,
+ isLoading,
item,
+ itemKey,
+ items,
+ onSearchClose,
+ scrollTrigger,
+ searchPlaceholder = 'Search...',
+ searchQuery = $bindable(),
+ selectedIndex,
+ showSearchInput,
skeleton,
- skeletonCount = 6,
- footer,
- scrollTrigger
+ skeletonCount = 6
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
- getTrigger: () => scrollTrigger,
+ dataIndex: 'picker',
getContainer: () => listContainer,
- getIndex: () => selectedIndex,
getCount: () => items.length,
- dataIndex: 'picker'
+ getIndex: () => selectedIndex,
+ getTrigger: () => scrollTrigger
});
</script>
}
let {
+ children,
class: className = '',
- isSelected = false,
+ dataIndex,
disabled = false,
+ isSelected = false,
onclick,
- onmouseenter,
- dataIndex,
- children
+ onmouseenter
}: Props = $props();
</script>
showBadge?: boolean;
}
- let { titleWidth = 'w-48', showBadge = false }: Props = $props();
+ let { showBadge = false, titleWidth = 'w-48' }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<script lang="ts">
- import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover';
+ import type { Snippet } from 'svelte';
interface Props {
class?: string;
}
let {
+ children,
class: className = '',
isOpen = $bindable(false),
- srLabel = 'Open picker',
onClose,
onKeydown,
- children
+ srLabel = 'Open picker'
}: Props = $props();
</script>
<script lang="ts">
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { debounce, uuid } from '$lib/utils';
- import { KeyboardKey } from '$lib/enums';
- import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
- import { SvelteMap } from 'svelte/reactivity';
import {
- ChatFormPickerPopover,
+ ChatFormPickerItemHeader,
ChatFormPickerList,
ChatFormPickerListItem,
- ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton,
+ ChatFormPickerPopover,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
+ import { KeyboardKey } from '$lib/enums';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
+ import { debounce, uuid } from '$lib/utils';
+ import { SvelteMap } from 'svelte/reactivity';
interface Props {
class?: string;
let {
class: className = '',
isOpen = false,
- searchQuery = '',
onClose,
- onPromptLoadStart,
onPromptLoadComplete,
- onPromptLoadError
+ onPromptLoadError,
+ onPromptLoadStart,
+ searchQuery = ''
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
-
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
+
if (firstInput) {
firstInput.focus();
}
promptError = null;
const placeholderId = uuid();
-
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
+
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
+
onPromptLoadError?.(placeholderId, errorMessage);
}
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
- serverName: selectedPrompt.serverName,
- promptName: selectedPrompt.name,
argName,
+ promptName: selectedPrompt.name,
+ serverName: selectedPrompt.serverName,
value
});
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
- value,
result,
- suggestionsCount: result?.values.length ?? 0
+ suggestionsCount: result?.values.length ?? 0,
+ value
});
}
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
+
return;
}
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
+
selectedPrompt = null;
promptArgs = {};
promptError = null;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
+
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
+
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
+
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
+
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
-
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
+
return orderA - orderB;
});
-
const query = (searchQuery || internalSearchQuery).toLowerCase();
+
if (!query) return sortedPrompts;
return sortedPrompts.filter(
<script lang="ts">
- import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
+ import type { MCPPromptInfo } from '$lib/types';
interface Props {
prompt: MCPPromptInfo;
}
let {
- prompt,
- promptArgs,
- suggestions,
- loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
- promptError,
- onArgInput,
- onArgKeydown,
+ loadingSuggestions,
onArgBlur,
onArgFocus,
+ onArgInput,
+ onArgKeydown,
+ onCancel,
onSelectSuggestion,
onSubmit,
- onCancel
+ prompt,
+ promptArgs,
+ promptError,
+ suggestions
}: Props = $props();
</script>
<script lang="ts">
- import type { MCPPromptInfo } from '$lib/types';
- import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
+ import type { MCPPromptInfo } from '$lib/types';
+ import { fly } from 'svelte/transition';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
let {
argument,
- value = '',
- suggestions = [],
- isLoadingSuggestions = false,
- isAutocompleteActive = false,
autocompleteIndex = 0,
- onInput,
- onKeydown,
+ isAutocompleteActive = false,
+ isLoadingSuggestions = false,
onBlur,
onFocus,
- onSelectSuggestion
+ onInput,
+ onKeydown,
+ onSelectSuggestion,
+ suggestions = [],
+ value = ''
}: Props = $props();
</script>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
- transition:fly={{ y: -5, duration: 100 }}
+ transition:fly={{ duration: 100, y: -5 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
}
let {
- isCommandPickerOpen,
commandQuery,
commands = [],
- onCommandPickerClose,
- onCommandSelect,
- isPromptPickerOpen,
- promptSearchQuery,
+ isCommandPickerOpen,
isMentionPickerOpen,
- mentionQuery,
+ isPromptPickerOpen,
mentionAnchor,
- scopePath,
- onPromptPickerClose,
- onMentionPickerClose,
+ mentionQuery,
+ onCommandPickerClose,
+ onCommandSelect,
onMentionOpened,
+ onMentionPickerClose,
onMentionSelect,
- onPromptLoadStart,
onPromptLoadComplete,
- onPromptLoadError
+ onPromptLoadError,
+ onPromptLoadStart,
+ onPromptPickerClose,
+ promptSearchQuery,
+ scopePath
}: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
+
return textareaElement.selectionStart ?? textareaElement.value.length;
}
<script lang="ts">
- import { FolderOpen } from '@lucide/svelte';
- import { ToolsService } from '$lib/services/tools.service';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
- import {
- abbreviateHome,
- buildCaseInsensitiveGlob,
- joinPath,
- lastPathSegment,
- runGlobSearchWithChildren,
- type GlobEntry
- } from '$lib/utils';
- import * as Popover from '$lib/components/ui/popover';
- import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
- import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
- import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
- import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
+ import { FolderOpen } from '@lucide/svelte';
+ import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
+ import * as Popover from '$lib/components/ui/popover';
import {
DEFAULT_MOBILE_BREAKPOINT,
HOME_TILDE,
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
} from '$lib/constants';
+ import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
+ import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
+ import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+ import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
+ import { ToolsService } from '$lib/services/tools.service';
+ import { toolsStore } from '$lib/stores/tools.svelte';
+ import {
+ abbreviateHome,
+ buildCaseInsensitiveGlob,
+ type GlobEntry,
+ joinPath,
+ lastPathSegment,
+ runGlobSearchWithChildren
+ } from '$lib/utils';
// Microtask delay so the popover's focus scope tears down first.
const FOCUS_DELAY_MS = 0;
let {
class: className = '',
- disabled = false,
+ customAnchor = null,
directory = null,
+ disabled = false,
isOpen,
- query = $bindable(''),
- customAnchor = null,
onChange,
onClose,
- onOpen
+ onOpen,
+ query = $bindable('')
}: Props = $props();
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
let listContainer = $state<HTMLDivElement | null>(null);
const nav = usePickerNavigation({
- isOpen: () => isOpen,
count: () => queryResults.length,
+ isOpen: () => isOpen,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
+
void toolsStore.resolveServerHome();
});
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
+
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
$effect(() => {
if (!isOpen) return;
+
const q = query.trim();
+
nav.reset(-1);
+
if (q && fileSearchEnabled) {
search.run(q);
} else {
});
useScrollActiveRow({
- getTrigger: () => nav.scrollTrigger,
+ dataIndex: 'result',
getContainer: () => listContainer,
- getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length,
- dataIndex: 'result'
+ getIndex: () => nav.hoveredIndex,
+ getTrigger: () => nav.scrollTrigger
});
let searchScope = $state(HOME_TILDE);
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
- debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
+ debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
+
if (!trimmed) {
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
+
return;
}
signal,
{ type: GlobSearchType.DIR }
);
+
if (!isCurrent()) return;
+
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
+
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
+
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
+
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
+
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
function setDirectory(value: string) {
const trimmed = value.trim();
+
if (!trimmed) return;
+
onChange?.(trimmed);
}
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
- path: homeBase ?? HOME_TILDE,
- type: GlobSearchType.DIR,
include: buildCaseInsensitiveGlob(name),
+ limit: NATIVE_LIMIT,
max_depth: NATIVE_MAX_DEPTH,
- limit: NATIVE_LIMIT
+ path: homeBase ?? HOME_TILDE,
+ type: GlobSearchType.DIR
});
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const match = entries.find(
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
);
+
return match ? joinPath(base, match.path) : null;
} catch {
return null;
async function browseNative() {
if (disabled || !window.showDirectoryPicker) return;
+
try {
const handle = await window.showDirectoryPicker();
const path = await resolveNativeName(handle.name);
+
if (path) {
setDirectory(path);
closePicker();
} catch (err) {
// user cancelled - silently ignore; other errors are logged
if (err instanceof DOMException && err.name === 'AbortError') return;
+
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
}
}
function handleSubmit() {
const value = query.trim();
+
if (!value) {
closePicker();
+
return;
}
+
setDirectory(value);
closePicker();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
+
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
+
if (directory) {
clearDirectory(event);
}
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
- import { abbreviateWorkingDir } from '$lib/utils';
- import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
- import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
+ import { abbreviateWorkingDir } from '$lib/utils';
interface Props {
directory?: string | null;
let {
directory = null,
- homeBase = null,
disabled = false,
- showTooltip = false,
- onClear
+ homeBase = null,
+ onClear,
+ showTooltip = false
}: Props = $props();
const displayLabel = $derived(
<script lang="ts">
import { Folder } from '@lucide/svelte';
- import { fly } from 'svelte/transition';
- import { highlightMatch } from '$lib/utils';
import { cn } from '$lib/components/ui/utils';
+ import { highlightMatch } from '$lib/utils';
+ import { fly } from 'svelte/transition';
// Fly-in transition for the results list.
const FLY_Y_PX = -4;
}
let {
- results,
+ container = $bindable(null),
+ error,
hoveredIndex,
isSearching,
- error,
- rawQuery,
- container = $bindable(null),
onCommit,
- onHover
+ onHover,
+ rawQuery,
+ results
}: Props = $props();
</script>
<div
bind:this={container}
class="max-h-48 overflow-y-auto py-2"
- transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }}
+ transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
>
{#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
<script lang="ts">
import { goto } from '$app/navigation';
- import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
- import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { DatabaseService } from '$lib/services/database.service';
- import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
- import { REASONING_TAGS } from '$lib/constants/agentic';
- import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
import {
ChatMessageAssistant,
- ChatMessageUser,
- ChatMessageSystem,
+ ChatMessageMcpPrompt,
ChatMessageSynthetic,
- ChatMessageMcpPrompt
+ ChatMessageSystem,
+ ChatMessageUser
} from '$lib/components/app/chat';
- import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
- import { deriveAgenticSections } from '$lib/utils';
- import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+ import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
+ import { REASONING_TAGS } from '$lib/constants/agentic';
import { ROUTES } from '$lib/constants/routes';
+ import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
+ import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
+ import { DatabaseService } from '$lib/services/database.service';
+ import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+ import { deriveAgenticSections } from '$lib/utils';
+ import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props {
class?: string;
let {
class: className = '',
- message,
- toolMessages = [],
isLastAssistantMessage = false,
isLastUserMessage = false,
+ message,
nextAssistantMessage = null,
- siblingInfo = null
+ siblingInfo = null,
+ toolMessages = []
}: Props = $props();
const chatActions = getChatActionsContext();
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
+
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
+
break;
case AgenticSectionType.TOOL_CALL:
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setMessageEditContext({
- get isEditing() {
- return isEditing;
- },
+ cancel: handleCancelEdit,
get editedContent() {
return editedContent;
},
get editedUploadedFiles() {
return editedUploadedFiles;
},
+ get isEditing() {
+ return isEditing;
+ },
+ get messageRole() {
+ return message.role;
+ },
get originalContent() {
return message.role === MessageRole.ASSISTANT
? (rawEditContent ?? message.content)
get originalExtras() {
return message.extra || [];
},
- get showSaveOnlyOption() {
- return showSaveOnlyOption;
- },
- get showBranchAfterEditOption() {
- return showBranchAfterEditOption;
- },
- get shouldBranchAfterEdit() {
- return shouldBranchAfterEdit;
- },
- get messageRole() {
- return message.role;
- },
get rawEditContent() {
return rawEditContent;
},
+ save: handleSaveEdit,
+ saveOnly: handleSaveEditOnly,
setContent: (content: string) => {
editedContent = content;
},
setExtras: (extras: DatabaseMessageExtra[]) => {
editedExtras = extras;
},
+ setShouldBranchAfterEdit: (value: boolean) => {
+ shouldBranchAfterEdit = value;
+ },
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
- setShouldBranchAfterEdit: (value: boolean) => {
- shouldBranchAfterEdit = value;
+ get shouldBranchAfterEdit() {
+ return shouldBranchAfterEdit;
+ },
+ get showBranchAfterEditOption() {
+ return showBranchAfterEditOption;
+ },
+ get showSaveOnlyOption() {
+ return showSaveOnlyOption;
},
- save: handleSaveEdit,
- saveOnly: handleSaveEditOnly,
- cancel: handleCancelEdit,
startEdit: handleEdit
});
let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null;
+
if (message.content.trim()) return null;
+
if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0];
function handleEdit() {
isEditing = true;
+
// Clear temporary placeholder content for system messages
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
editedContent = '';
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (isMobile.current) return;
+
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
// If content is empty, remove without deleting children
if (!newContent) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
+
isEditing = false;
+
if (conversationDeleted) {
goto(ROUTES.START);
} else {
focusMainChatForm();
}
+
return;
}
await DatabaseService.updateMessage(message.id, { content: newContent });
const index = conversationsStore.findMessageIndex(message.id);
+
if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent });
}
+
focusMainChatForm();
} else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras();
+
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else {
// For assistant messages, preserve exact content including trailing whitespace
if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras();
+
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
}
<script lang="ts">
import {
- ChatMessageAgenticContent,
ChatMessageActionIcons,
+ ChatMessageAgenticContent,
ChatMessageAssistantModel,
ChatMessageAssistantProcessingInfo,
ChatMessageAssistantRawOutput,
ChatMessageEditForm
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
- import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
- import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
- import { modelLoadProgressText } from '$lib/utils';
import { MessageRole } from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
-
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { modelLoadProgressText } from '$lib/utils';
import { hasAgenticContent } from '$lib/utils';
interface Props {
deletionInfo,
isLastAssistantMessage = false,
message,
- toolMessages = [],
onConfirmDelete,
onContinue,
onCopy,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
- textareaElement = $bindable()
+ textareaElement = $bindable(),
+ toolMessages = []
}: Props = $props();
// Get edit context
if (!userMessageEl) {
lastUserMessageHeight = 0;
+
return;
}
const updateHeight = () => {
const rect = userMessageEl.getBoundingClientRect();
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
+
lastUserMessageHeight = Math.round(rect.height + marginTop);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
+
resizeObserver.observe(userMessageEl);
return () => {
<script lang="ts">
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
- import { copyToClipboard } from '$lib/utils';
- import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { copyToClipboard } from '$lib/utils';
interface Props {
displayedModel: string | null;
onRegenerate: (modelOverride?: string) => void;
}
- let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
+ let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
let pendingModel = $state<string | null>(null);
}
onRegenerate(modelName);
+
return true;
}}
/>
<script lang="ts">
- import { fade } from 'svelte/transition';
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
+ import { fade } from 'svelte/transition';
interface Props {
modelLoadingText: string | null;
position: 'top' | 'bottom';
}
- let { modelLoadingText, processingState, position }: Props = $props();
+ let { modelLoadingText, position, processingState }: Props = $props();
const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
</script>
<script lang="ts">
- import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
+ import { buildAssistantRawOutput, deriveAgenticSections } from '$lib/utils';
interface Props {
message: DatabaseMessage;
let rawOutputContent = $derived.by(() => {
const sections = deriveAgenticSections(message, toolMessages, [], false);
+
return buildAssistantRawOutput(sections);
});
</script>
showMessageStats: boolean;
}
- let { message, isLoading, processingState, showMessageStats }: Props = $props();
+ let { isLoading, message, processingState, showMessageStats }: Props = $props();
</script>
{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
<script lang="ts">
import { Folder, FolderX } from '@lucide/svelte';
- import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
+ import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
- import { MessageRole, McpPromptVariant } from '$lib/enums';
+ import { McpPromptVariant, MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
let {
class: className = '',
- message,
- mcpPrompt,
- siblingInfo = null,
- showDeleteDialog,
deletionInfo,
+ mcpPrompt,
+ message,
+ onConfirmDelete,
onCopy,
- onEdit,
onDelete,
- onConfirmDelete,
+ onEdit,
onNavigateToSibling,
- onShowDeleteDialogChange
+ onShowDeleteDialogChange,
+ showDeleteDialog,
+ siblingInfo = null
}: Props = $props();
// Get edit context
<script lang="ts">
+ import { TruncatedText } from '$lib/components/app/misc';
import { Card } from '$lib/components/ui/card';
- import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { McpPromptVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
- import { McpPromptVariant } from '$lib/enums';
- import { TruncatedText } from '$lib/components/app/misc';
- import * as Tooltip from '$lib/components/ui/tooltip';
interface ContentPart {
text: string;
let {
class: className = '',
- prompt,
- variant = McpPromptVariant.MESSAGE,
isLoading = false,
- loadError
+ loadError,
+ prompt,
+ variant = McpPromptVariant.MESSAGE
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
- return [{ text: prompt.content || '', argKey: null }];
+ return [{ argKey: null, text: prompt.content || '' }];
}
const parts: ContentPart[] = [];
+
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
+
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
for (const value of sortedValues) {
const index = remaining.indexOf(value);
+
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
- earliestMatch = { index, value, key: valueToKey.get(value)! };
+ earliestMatch = { index, key: valueToKey.get(value)!, value };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
- parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
+ parts.push({ argKey: null, text: remaining.slice(0, earliestMatch.index) });
}
- parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
+ parts.push({ argKey: earliestMatch.key, text: earliestMatch.value });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
- parts.push({ text: remaining, argKey: null });
+ parts.push({ argKey: null, text: remaining });
break;
}
<script lang="ts">
- import { parseCwdMessage } from '$lib/utils';
- import type { DatabaseMessage } from '$lib/types';
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
+ import type { DatabaseMessage } from '$lib/types';
+ import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
let {
class: className = '',
- message,
- siblingInfo = null,
- showDeleteDialog,
deletionInfo,
+ message,
+ onConfirmDelete,
onCopy,
- onEdit,
onDelete,
- onConfirmDelete,
+ onEdit,
onNavigateToSibling,
onShowDeleteDialogChange,
+ showDeleteDialog,
+ siblingInfo = null,
textareaElement = $bindable()
}: Props = $props();
<script lang="ts">
- import { BuiltInTool } from '$lib/enums';
- import {
- extractSearchQuery,
- extractSearchResults,
- isWebSearchToolName,
- type AgenticSection
- } from '$lib/utils';
- import type { DatabaseMessageExtra } from '$lib/types';
import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
+ import { BuiltInTool } from '$lib/enums';
+ import type { DatabaseMessageExtra } from '$lib/types';
+ import {
+ type AgenticSection,
+ extractSearchQuery,
+ extractSearchResults,
+ isWebSearchToolName
+ } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props();
+ let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
const searchResults = $derived(extractSearchResults(section.toolResult));
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
// Renders section.toolArgs / section.toolResult directly using the
// shared chrome shell.
+ import ToolCallBlock from './ToolCallBlock.svelte';
import { Loader2 } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
- import { FileTypeText, ToolResultKind } from '$lib/enums';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
+ import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
+ import { FileTypeText, ToolResultKind } from '$lib/enums';
+ import type { DatabaseMessageExtra } from '$lib/types';
import {
+ type AgenticSection,
classifyToolResult,
formatJsonPretty,
- parseToolResultWithImages,
- type AgenticSection
+ parseToolResultWithImages
} from '$lib/utils';
- import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
- import type { DatabaseMessageExtra } from '$lib/types';
- import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, attachments, onToggle }: Props = $props();
+ let { attachments, isStreaming, onToggle, open, section }: Props = $props();
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult));
<script lang="ts">
+ import { parseEditFileMeta } from './parsers/edit-file';
+ import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
- import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { parseEditFileMeta } from './parsers/edit-file';
- import ToolCallBlock from './ToolCallBlock.svelte';
+ import { abbreviateHome, type AgenticSection, computeLineDiff, prefixFor } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const editFileMeta = $derived(parseEditFileMeta(section));
const home = $derived(toolsStore.serverHome);
// The scroll-to-bottom auto-scroll logic mirrors what was here
// before extraction.
- import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
+ import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
+ import ToolCallBlock from './ToolCallBlock.svelte';
+ import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS } from '$lib/constants';
- import { config } from '$lib/stores/settings.svelte';
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+ import { config } from '$lib/stores/settings.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
+ import type { DatabaseMessageExtra } from '$lib/types';
import {
abbreviateHome,
+ type AgenticSection,
+ type ExecShellExitStatus,
highlightCode,
isExitCodeSummaryLine,
parseExecShellCommandError,
parseExecShellCommandExitStatus,
parseToolResultWithImages,
- type AgenticSection,
- type ExecShellExitStatus,
type ToolResultLine
} from '$lib/utils';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
- import type { DatabaseMessageExtra } from '$lib/types';
- import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props();
+ let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
// `isLive` covers all in-flight phases: pre-chunk spinner and
// streaming itself. Frozen output (tool done while agent continues)
function isAtBottom(): boolean {
if (!scrollEl) return false;
+
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
function handleScrollEvent() {
if (!scrollEl) return;
+
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
+
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
void section.toolResult;
+
if (!scrollEl || !autoScroll) return;
+
scrollToBottomOnFrame();
});
if (!scrollEl || !autoScroll) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
+
observer.observe(scrollEl, {
+ characterData: true,
childList: true,
- subtree: true,
- characterData: true
+ subtree: true
});
return () => observer.disconnect();
<script lang="ts">
- import { XCircle } from '@lucide/svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
- import { toolsStore } from '$lib/stores/tools.svelte';
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte';
+ import { XCircle } from '@lucide/svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
+ import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
const home = $derived(toolsStore.serverHome);
isStreaming?: boolean;
}
- let { section, isStreaming = false }: Props = $props();
+ let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
+
if (typeof obj.error === 'string') return { errorMessage: obj.error };
+
if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
}
} catch {
<script lang="ts">
import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
+ import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
isStreaming?: boolean;
}
- let { section, isStreaming = false }: Props = $props();
+ let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
+
if (typeof obj.error === 'string') return { errorMessage: obj.error };
+
return {
- os: typeof obj.os === 'string' ? obj.os : undefined,
- cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
+ cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
+ os: typeof obj.os === 'string' ? obj.os : undefined
};
}
} catch {
<script lang="ts">
- import { XCircle } from '@lucide/svelte';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
- import { toolsStore } from '$lib/stores/tools.svelte';
import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte';
+ import { XCircle } from '@lucide/svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
+ import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const grepMeta = $derived(parseGrepSearchMeta(section));
const home = $derived(toolsStore.serverHome);
<script lang="ts">
+ import { parseReadFileMeta } from './parsers/read-file';
+ import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { type AgenticSection } from '$lib/utils';
- import { parseReadFileMeta } from './parsers/read-file';
- import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const readFileMeta = $derived(parseReadFileMeta(section));
</script>
<script lang="ts">
- import { XCircle, Terminal } from '@lucide/svelte';
- import { SyntaxHighlightedCode } from '$lib/components/app';
- import { FileTypeText } from '$lib/enums';
- import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
- import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
import { parseRunJavascriptMeta } from './parsers/run-javascript';
import ToolCallBlock from './ToolCallBlock.svelte';
+ import { Terminal, XCircle } from '@lucide/svelte';
+ import { SyntaxHighlightedCode } from '$lib/components/app';
+ import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
+ import { FileTypeText } from '$lib/enums';
+ import { type AgenticSection, getBuiltinToolUi } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const runJsMeta = $derived(parseRunJavascriptMeta(section));
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
<script lang="ts">
- import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { Globe, Loader2 } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import * as HoverCard from '$lib/components/ui/hover-card';
+ import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
- extractSearchResults,
+ type AgenticSection,
extractSearchQuery,
+ extractSearchResults,
faviconForUrl,
sanitizeExternalUrl,
- type SearchResult,
- type AgenticSection
+ type SearchResult
} from '$lib/utils';
interface Props {
onToggle?: () => void;
}
- let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props();
+ let { isStreaming = false, onToggle, open = $bindable(false), section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
// retrospective.
const title = $derived.by(() => {
const verb = showSpinner ? 'Searching' : 'Searched';
+
return query ? `${verb} web for "${query}"` : `${verb} web`;
});
function formatPublishDate(iso: string | undefined): string | null {
if (!iso) return null;
+
try {
const date = new Date(iso);
+
if (Number.isNaN(date.getTime())) return iso;
+
return date.toLocaleDateString(undefined, {
- year: 'numeric',
+ day: 'numeric',
month: 'short',
- day: 'numeric'
+ year: 'numeric'
});
} catch {
return iso;
<script lang="ts">
+ import { parseWriteFileMeta } from './parsers/write-file';
+ import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
- import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { parseWriteFileMeta } from './parsers/write-file';
- import ToolCallBlock from './ToolCallBlock.svelte';
+ import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
onToggle?: () => void;
}
- let { section, open, isStreaming, onToggle }: Props = $props();
+ let { isStreaming, onToggle, open, section }: Props = $props();
const writeFileMeta = $derived(parseWriteFileMeta(section));
const home = $derived(toolsStore.serverHome);
import { Loader2, Wrench } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
+ import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums';
- import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import type { Component, Snippet } from 'svelte';
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
+ import type { Component, Snippet } from 'svelte';
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
}
let {
- section,
- open,
+ children,
+ extraLiveStreaming = false,
isStreaming,
meta,
- extraLiveStreaming = false,
+ onToggle,
+ open,
+ section,
spinIconWhenActive = false,
- wrapper: Wrapper = CollapsibleContentBlock,
title,
titleSnippet,
- onToggle,
- children
+ wrapper: Wrapper = CollapsibleContentBlock
}: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (showSpinner) return undefined;
+
if (errorMessage) return 'failed';
+
if (isStreamingCall && !isStreaming) return 'incomplete';
+
return undefined;
}
{onToggle}
>
{@render children(meta, {
- isStreaming,
+ isCodeStreaming,
isPending,
- isStreamingCall,
- isCodeStreaming
+ isStreaming,
+ isStreamingCall
})}
</Wrapper>
// stay focused on its own format quirks.
import { BuiltInTool } from '$lib/enums';
-import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
import type { AgenticSection } from '$lib/utils/agentic';
+import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
/**
* Strict (final-state) JSON parser for a tool-args blob. Mirrors the
function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(blob);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
+
return null;
} catch {
return null;
options: { partial?: boolean } = {}
): Record<string, unknown> | null {
if (section.toolName !== expected || !section.toolArgs) return null;
+
return options.partial
? parsePartialJsonArgs(section.toolArgs)
: parseFinalToolArgs(section.toolArgs);
// rendering), plus the result blob for `result` / `edits_applied` /
// `error` fields.
-import { BuiltInTool } from '$lib/enums';
-import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
-import { tryParseToolResultObject, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
+import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
+import { BuiltInTool } from '$lib/enums';
+import { type AgenticSection, tryParseToolResultObject } from '$lib/utils';
export type EditFileEdit = {
oldText: string;
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
+
if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath;
+
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
-
// Filter the streamed edits array strictly: each entry must be an
// object with a non-empty `old_text`. Edits without an old_text
// would diff against empty and render as a full re-write.
const rawEdits = Array.isArray(args.edits) ? args.edits : [];
const edits: EditFileEdit[] = [];
+
for (const e of rawEdits) {
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
+
const obj = e as Record<string, unknown>;
const oldText = typeof obj.old_text === 'string' ? obj.old_text : '';
+
if (!oldText) continue;
+
const newText = typeof obj.new_text === 'string' ? obj.new_text : '';
- edits.push({ oldText, newText });
+
+ edits.push({ newText, oldText });
}
const resultObj = tryParseToolResultObject(section.toolResult);
+
let resultMessage: string | undefined;
let editsApplied: number | undefined;
let errorMessage: string | undefined;
+
if (typeof resultObj?.error === 'string') {
errorMessage = resultObj.error;
} else if (resultObj) {
if (typeof resultObj.result === 'string') {
resultMessage = resultObj.result;
}
+
if (Number.isFinite(Number(resultObj.edits_applied))) {
editsApplied = Number(resultObj.edits_applied);
}
}
return {
- fileName,
- filePath: rawPath,
edits,
- resultMessage,
editsApplied,
- errorMessage
+ errorMessage,
+ fileName,
+ filePath: rawPath,
+ resultMessage
};
}
// file only deals with what's strictly about *calling* the tool, since
// the error / exit status elide from call-section to result-section.
+import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils';
-import { parseToolArgs } from './_shared';
export type ExecShellCommandMeta = {
command: string;
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
+
if (!args) return null;
const commandRaw = args.command ?? args.cmd ?? args.shell_command;
+
if (typeof commandRaw !== 'string' || !commandRaw) return null;
+
return { command: commandRaw };
}
// parser keeps the original raw-text fallback for MCP servers that
// emit unparseable output.
-import { BuiltInTool } from '$lib/enums';
-import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
+import { BuiltInTool } from '$lib/enums';
+import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type FileGlobSearchMeta = {
path: string;
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
+
if (!args) return null;
const path = typeof args.path === 'string' ? args.path : '';
const include = typeof args.include === 'string' && args.include ? args.include : '**';
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
+
if (!path) return null;
let matches: string[] = [];
let errorMessage: string | undefined;
const toolResultString = section.toolResult;
+
if (toolResultString) {
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
+
if (typeof obj.error === 'string') {
errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total;
});
+
matches = split.lines;
}
}
const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total;
});
+
matches = split.lines;
}
}
- return { path, include, exclude, matches, totalMatches, errorMessage };
+ return { errorMessage, exclude, include, matches, path, totalMatches };
}
// fallback so MCP servers that return unparseable output still get
// surfaced.
-import { BuiltInTool } from '$lib/enums';
-import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
+import { BuiltInTool } from '$lib/enums';
+import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type GrepSearchMatch = {
file: string;
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
+
if (!args) return null;
const path = typeof args.path === 'string' ? args.path : '';
const pattern = typeof args.pattern === 'string' ? args.pattern : '';
+
if (!path || !pattern) return null;
const include = typeof args.include === 'string' && args.include ? args.include : '**';
let errorMessage: string | undefined;
const toolResultString = section.toolResult;
+
if (toolResultString) {
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
+
if (typeof obj.error === 'string') {
errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total;
});
+
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
}
}
const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total;
});
+
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
}
}
return {
+ errorMessage,
+ exclude,
+ include,
+ matches,
path,
pattern,
- include,
- exclude,
showLineNumbers,
- matches,
- totalMatches,
- errorMessage
+ totalMatches
};
}
// <file>:<content> when return_line_numbers=false
// <file>:<lineno>:<content> when return_line_numbers=true
const firstColon = line.indexOf(':');
+
if (firstColon === -1) {
- return { file: line, content: '' };
+ return { content: '', file: line };
}
+
const file = line.slice(0, firstColon);
const tail = line.slice(firstColon + 1);
if (!showLineNumbers) {
- return { file, content: tail };
+ return { content: tail, file };
}
const secondColon = tail.indexOf(':');
+
if (secondColon === -1) {
- return { file, content: tail };
+ return { content: tail, file };
}
+
const lineNum = parseInt(tail.slice(0, secondColon), 10);
+
return {
+ content: tail.slice(secondColon + 1),
file,
- line: Number.isFinite(lineNum) ? lineNum : undefined,
- content: tail.slice(secondColon + 1)
+ line: Number.isFinite(lineNum) ? lineNum : undefined
};
}
// `start_line`+`line_count`). Args are parsed partially so a header
// can render incrementally as the file path streams in.
-import { BuiltInTool } from '$lib/enums';
+import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
-import { getFileTypeByExtension, type AgenticSection } from '$lib/utils';
-import { parseToolArgs } from './_shared';
+import { BuiltInTool } from '$lib/enums';
+import { type AgenticSection, getFileTypeByExtension } from '$lib/utils';
export type ReadFileMeta = {
fileName: string;
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
+
if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath;
+
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
-
// Models emit range arguments under several aliases. Accept all to
// stay forgiving across prompt variations.
const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line;
const countRaw = args.line_count ?? args.count ?? args.num_lines;
let lineRange: { start: number; end: number } | null = null;
+
const sNum = Number(startRaw);
const eNum = Number(endRaw);
+
if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) {
- lineRange = { start: sNum, end: eNum };
+ lineRange = { end: eNum, start: sNum };
} else if (startRaw != null && countRaw != null) {
const cNum = Number(countRaw);
+
if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
- lineRange = { start: sNum, end: sNum + cNum - 1 };
+ lineRange = { end: sNum + cNum - 1, start: sNum };
}
}
const fileType = getFileTypeByExtension(fileName);
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
- return { fileName, lineRange, language };
+ return { fileName, language, lineRange };
}
// failure renders as a flat line beginning with `Error:`. Both shapes
// are handled.
+import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils';
-import { parseToolArgs } from './_shared';
export type RunJavascriptMeta = {
code: string;
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
+
if (!args) return null;
const code = typeof args.code === 'string' ? args.code : '';
+
if (!code) return null;
const timeoutRaw = Number(args.timeout_ms);
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
let errorMessage: string | undefined;
+
const toolResultString = section.toolResult;
+
if (toolResultString) {
// Branches matter here: a JSON object can carry `error`, but a
// JSON array always represents successful output (sandbox returns
// the array of values). Only when the result isn't a JSON object
// do we scan raw lines for the `Error:` prefix.
let parsedObject: Record<string, unknown> | null = null;
+
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedObject = parsed as Record<string, unknown>;
}
} catch {
parsedObject = null;
}
+
if (typeof parsedObject?.error === 'string') {
errorMessage = parsedObject.error;
} else if (!parsedObject) {
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('Error:'));
+
if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim();
}
}
- return { code, timeoutMs, errorMessage };
+ return { code, errorMessage, timeoutMs };
}
// finishes) and surfaces `bytes`, `result`, and `error` from the
// result blob.
-import { BuiltInTool } from '$lib/enums';
+import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
-import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils';
-import { parseToolArgs } from './_shared';
+import { BuiltInTool } from '$lib/enums';
+import { type AgenticSection, getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
export type WriteFileMeta = {
fileName: string;
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
+
if (!args) return null;
// Tool contracts drifted over time: some models emit `path`,
// others `file_path` / `filePath`. Accept all three.
const rawPath = args.path ?? args.file_path ?? args.filePath;
+
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
const content = typeof args.content === 'string' ? args.content : '';
const language =
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
-
const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
return {
+ bytesWritten,
+ content,
+ errorMessage,
fileName,
filePath: rawPath,
language,
- content,
- bytesWritten,
- resultMessage,
- errorMessage
+ resultMessage
};
}
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts';
+ import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
- import { MessageRole, ChatMessageStatisticsMode } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
interface Props {
let {
class: className = '',
- message,
- siblingInfo = null,
deletionInfo,
isLastUserMessage = false,
+ message,
nextAssistantMessage = null,
- showDeleteDialog,
- onEdit,
- onDelete,
onConfirmDelete,
+ onCopy,
+ onDelete,
+ onEdit,
onForkConversation,
- onShowDeleteDialogChange,
onNavigateToSibling,
- onCopy
+ onShowDeleteDialogChange,
+ showDeleteDialog,
+ siblingInfo = null
}: Props = $props();
// Get contexts
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => {
const timings = nextAssistantMessage?.timings;
+
if (!timings?.prompt_n || !timings?.prompt_ms) return null;
const agentic = timings.agentic;
return {
- promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n,
- promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms
+ promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms,
+ promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n
};
});
<script lang="ts">
- import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+ import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
}
let {
- content,
attachments = [],
- renderMarkdown = false,
- textColorClass = 'text-foreground',
cardBgClass = 'dark:bg-primary/15',
- maxHeightStyle = ''
+ content,
+ maxHeightStyle = '',
+ renderMarkdown = false,
+ textColorClass = 'text-foreground'
}: Props = $props();
let isMultiline = $state(false);
if (content.includes('\n')) {
isMultiline = true;
+
return;
}
<script lang="ts">
- import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
+ import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
interface Props {
class: className = '',
content,
extras = [],
- onSendImmediately,
+ onDelete,
onEdit,
- onDelete
+ onSendImmediately
}: Props = $props();
const editCtx = useMessageEditContext({
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import type { Snippet, Component } from 'svelte';
+ import type { Component, Snippet } from 'svelte';
interface Props {
icon: Component<{ class?: string }>;
actions: Snippet;
}
- let { icon: IconComponent, message, actions }: Props = $props();
+ let { actions, icon: IconComponent, message }: Props = $props();
</script>
<div class="my-2 rounded-lg border border-border bg-card p-3">
<script lang="ts">
+ import ChatMessageActionCard from './ChatMessageActionCard.svelte';
import { RotateCw } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
- import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props {
onDecision: (shouldContinue: boolean) => void;
import { ChatMessageActionCard } from '$lib/components/app';
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
- import { cn } from '$lib/components/ui/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import { ToolSource, ToolPermissionDecision } from '$lib/enums';
+ import { cn } from '$lib/components/ui/utils';
import { TOOL_SERVER_LABELS } from '$lib/constants';
+ import { ToolPermissionDecision, ToolSource } from '$lib/enums';
import { toolsStore } from '$lib/stores/tools.svelte';
interface Props {
onDecision: (decision: ToolPermissionDecision) => void;
}
- let { toolName, serverLabel, onDecision }: Props = $props();
+ let { onDecision, serverLabel, toolName }: Props = $props();
</script>
<ChatMessageActionCard icon={ShieldQuestion}>
<DropdownMenu.Trigger
class={cn(
- buttonVariants({ variant: 'secondary', size: 'sm' }),
+ buttonVariants({ size: 'sm', variant: 'secondary' }),
'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
)}
aria-label="More allow options"
<script lang="ts">
- import { Edit, Copy, RefreshCw, Trash2, ArrowRight, GitBranch } from '@lucide/svelte';
+ import { ArrowRight, Copy, Edit, GitBranch, RefreshCw, Trash2 } from '@lucide/svelte';
import {
ActionIcon,
ChatMessageActionIconsBranchingControls,
DialogConfirmation
} from '$lib/components/app';
- import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
+ import { Switch } from '$lib/components/ui/switch';
import { MessageRole } from '$lib/enums';
import { activeConversation } from '$lib/stores/conversations.svelte';
actionsPosition,
deletionInfo,
justify,
- onCopy,
- onEdit,
onConfirmDelete,
onContinue,
+ onCopy,
onDelete,
+ onEdit,
onForkConversation,
onNavigateToSibling,
- onShowDeleteDialogChange,
+ onRawOutputToggle,
onRegenerate,
+ onShowDeleteDialogChange,
+ rawOutputEnabled = false,
role,
- siblingInfo = null,
showDeleteDialog,
showRawOutputSwitch = false,
- rawOutputEnabled = false,
- onRawOutputToggle
+ siblingInfo = null
}: Props = $props();
let showForkDialog = $state(false);
}
function handleConfirmFork() {
- onForkConversation?.({ name: forkName.trim(), includeAttachments: forkIncludeAttachments });
+ onForkConversation?.({ includeAttachments: forkIncludeAttachments, name: forkName.trim() });
showForkDialog = false;
}
</script>
onNavigateToSibling?: (siblingId: string) => void;
}
- let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
+ let { class: className = '', onNavigateToSibling, siblingInfo }: Props = $props();
let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
<script lang="ts">
+ import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
+ import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
import {
- ChatMessageStatistics,
- MarkdownContent,
+ ChatMessageActionCardContinueRequest,
ChatMessageActionCardPermissionRequest,
- ChatMessageActionCardContinueRequest
+ ChatMessageStatistics,
+ MarkdownContent
} from '$lib/components/app';
-
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
- import type {
- ChatMessageAgenticTimings,
- ChatMessageAgenticTurnStats,
- DatabaseMessage
- } from '$lib/types';
- import { deriveAgenticSections, type AgenticSection } from '$lib/utils';
import {
- agenticPendingPermissionRequest,
- agenticResolvePermission,
+ agenticExecutingToolCallId,
+ agenticLastError,
agenticPendingContinueRequest,
+ agenticPendingPermissionRequest,
agenticResolveContinue,
- agenticLastError,
- agenticExecutingToolCallId
+ agenticResolvePermission
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
- import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
- import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
+ import type {
+ ChatMessageAgenticTimings,
+ ChatMessageAgenticTurnStats,
+ DatabaseMessage
+ } from '$lib/types';
+ import { type AgenticSection, deriveAgenticSections } from '$lib/utils';
interface Props {
message: DatabaseMessage;
}
let {
- message,
- toolMessages = [],
+ isLastAssistantMessage = false,
isStreaming = false,
- isLastAssistantMessage = false
+ message,
+ toolMessages = []
}: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
$effect(() => {
if (pendingPermission !== prevPendingRef) {
prevPendingRef = pendingPermission;
+
if (pendingPermission) {
permissionDismissed = false;
}
$effect(() => {
if (pendingContinue !== prevContinueRef) {
prevContinueRef = pendingContinue;
+
if (pendingContinue) {
continueDismissed = false;
}
const turnGroups: TurnGroup[] = $derived.by(() => {
const groups: TurnGroup[] = [];
+
let currentTurn: AgenticSection[] = [];
let currentIndices: number[] = [];
let prevWasTool = false;
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
if (!isTool && prevWasTool && currentTurn.length > 0) {
- groups.push({ sections: currentTurn, flatIndices: currentIndices });
+ groups.push({ flatIndices: currentIndices, sections: currentTurn });
currentTurn = [];
currentIndices = [];
}
}
if (currentTurn.length > 0) {
- groups.push({ sections: currentTurn, flatIndices: currentIndices });
+ groups.push({ flatIndices: currentIndices, sections: currentTurn });
}
return groups;
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return {
- turns: 1,
+ llm: stats.llm,
+ toolCalls: stats.toolCalls,
toolCallsCount: stats.toolCalls.length,
toolsMs: stats.toolsMs,
- toolCalls: stats.toolCalls,
- llm: stats.llm
+ turns: 1
};
}
</script>
<script lang="ts">
- import { X, AlertTriangle } from '@lucide/svelte';
+ import { AlertTriangle, X } from '@lucide/svelte';
+ import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
- import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
let hasUnsavedChanges = $derived.by(() => {
if (editCtx.editedContent !== editCtx.originalContent) return true;
+
if (editCtx.editedUploadedFiles.length > 0) return true;
const extrasChanged =
function handleAttachmentRemove(index: number) {
const newExtras = [...editCtx.editedExtras];
+
newExtras.splice(index, 1);
editCtx.setExtras(newExtras);
}
function handleUploadedFileRemove(fileId: string) {
const newFiles = editCtx.editedUploadedFiles.filter((f) => f.id !== fileId);
+
editCtx.setUploadedFiles(newFiles);
}
async function handleFilesAdd(files: File[]) {
const processed = await processFilesToChatUploaded(files);
+
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
}
<script lang="ts">
import { Lightbulb } from '@lucide/svelte';
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
- import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+ import { AgenticSectionType } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
}
let {
- section,
- open,
- isStreaming,
- hasReasoningError = false,
attachments,
- onToggle
+ hasReasoningError = false,
+ isStreaming,
+ onToggle,
+ open,
+ section
}: Props = $props();
const currentConfig = config();
if (isPending && !isStreaming) {
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
}
+
if (section.wasInterrupted) {
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
}
+
return isStreaming ? '' : undefined;
});
const shimmerTitle = $derived(isPending && isStreaming);
function isAtBottom(): boolean {
if (!scrollEl) return false;
+
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
+
// User may scroll between scheduling and paint.
if (scrollEl && !userScrolledUp) {
scrollEl.scrollTop = scrollEl.scrollHeight;
function handleScrollEvent() {
if (!scrollEl) return;
+
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
+
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
void section.content;
+
if (!scrollEl || !isPending || !isStreaming) return;
+
scrollToBottomOnFrame();
});
if (!scrollEl || !isPending || !isStreaming) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
+
observer.observe(scrollEl, {
+ characterData: true,
childList: true,
- subtree: true,
- characterData: true
+ subtree: true
});
return () => observer.disconnect();
<script lang="ts">
- import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
+ import { BookOpenText, Clock, Gauge, Layers, Sparkles, WholeWord, Wrench } from '@lucide/svelte';
import { ChatMessageStatisticsBadge } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { ChatMessageStatsView, ChatMessageStatisticsMode } from '$lib/enums';
+ import { DEFAULT_PERFORMANCE_TIME, MS_PER_SECOND } from '$lib/constants';
+ import { ChatMessageStatisticsMode, ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
- import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
import type { Component } from 'svelte';
interface Props {
}
let {
- predictedTokens,
- predictedMs,
- promptTokens,
- promptMs,
+ agenticTimings,
+ hideSummary = false,
+ initialView = ChatMessageStatsView.GENERATION,
isLive = false,
isProcessingPrompt = false,
- initialView = ChatMessageStatsView.GENERATION,
- agenticTimings,
+ mode = ChatMessageStatisticsMode.SWITCHABLE,
onActiveViewChange,
- hideSummary = false,
- mode = ChatMessageStatisticsMode.SWITCHABLE
+ predictedMs,
+ predictedTokens,
+ promptMs,
+ promptTokens
}: Props = $props();
let isSwitchable = $derived(mode === ChatMessageStatisticsMode.SWITCHABLE);
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive}
{@render viewButton({
- view: ChatMessageStatsView.READING,
icon: BookOpenText,
label: 'Reading',
- tooltipText: 'Processing'
+ tooltipText: 'Processing',
+ view: ChatMessageStatsView.READING
})}
{/if}
{@render viewButton({
- view: ChatMessageStatsView.GENERATION,
+ disabled: isGenerationDisabled,
icon: Sparkles,
label: 'Generation',
tooltipText: isGenerationDisabled ? 'Waiting for tokens...' : 'Generation',
- disabled: isGenerationDisabled
+ view: ChatMessageStatsView.GENERATION
})}
{#if hasAgenticStats}
{@render viewButton({
- view: ChatMessageStatsView.TOOLS,
icon: Wrench,
label: 'Tools',
- tooltipText: 'Tool calls'
+ tooltipText: 'Tool calls',
+ view: ChatMessageStatsView.TOOLS
})}
{#if !hideSummary}
{@render viewButton({
- view: ChatMessageStatsView.SUMMARY,
icon: Layers,
label: 'Summary',
- tooltipText: 'Agentic summary'
+ tooltipText: 'Agentic summary',
+ view: ChatMessageStatsView.SUMMARY
})}
{/if}
{/if}
tooltipLabel?: string;
}
- let { class: className = '', icon: IconComponent, value, tooltipLabel }: Props = $props();
+ let { class: className = '', icon: IconComponent, tooltipLabel, value }: Props = $props();
function handleClick() {
void copyToClipboard(String(value));
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
+ import {
+ agenticClearSteeringMessage,
+ agenticInjectSteeringMessage,
+ agenticPendingSteeringMessageContent,
+ agenticPendingSteeringMessageExtras
+ } from '$lib/stores/agentic.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import {
- chatPendingMessageContent,
- chatPendingMessageExtras,
chatClearPendingMessage,
- chatInjectPendingMessage
+ chatInjectPendingMessage,
+ chatPendingMessageContent,
+ chatPendingMessageExtras
} from '$lib/stores/chat.svelte';
- import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
+ import { activeConversation, conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
- import {
- agenticPendingSteeringMessageContent,
- agenticPendingSteeringMessageExtras,
- agenticClearSteeringMessage,
- agenticInjectSteeringMessage
- } from '$lib/stores/agentic.svelte';
import {
buildSiblingInfoMap,
copyToClipboard,
onMessagesReady?: (messageCount: number) => void;
}
- let { messages = [], onUserAction, onMessagesReady }: Props = $props();
+ let { messages = [], onMessagesReady, onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
setChatActionsContext({
+ continueAssistantMessage: async (message: DatabaseMessage) => {
+ onUserAction?.();
+ await chatStore.continueAssistantMessage(message.id);
+ refreshAllMessages();
+ },
+
copy: async (message: DatabaseMessage) => {
const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(
message.extra,
asPlainText
);
+
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
},
refreshAllMessages();
},
- navigateToSibling: async (siblingId: string) => {
- await conversationsStore.navigateToSibling(siblingId);
+ editUserMessagePreserveResponses: async (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => {
+ onUserAction?.();
+ await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
+ refreshAllMessages();
},
editWithBranching: async (
refreshAllMessages();
},
- editUserMessagePreserveResponses: async (
+ forkConversation: async (
message: DatabaseMessage,
- newContent: string,
- newExtras?: DatabaseMessageExtra[]
+ options: { name: string; includeAttachments: boolean }
) => {
- onUserAction?.();
- await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
- refreshAllMessages();
+ await conversationsStore.forkConversation(message.id, options);
},
- regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
- onUserAction?.();
- await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
- refreshAllMessages();
+ navigateToSibling: async (siblingId: string) => {
+ await conversationsStore.navigateToSibling(siblingId);
},
- continueAssistantMessage: async (message: DatabaseMessage) => {
+ regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
- await chatStore.continueAssistantMessage(message.id);
+ await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
- },
-
- forkConversation: async (
- message: DatabaseMessage,
- options: { name: string; includeAttachments: boolean }
- ) => {
- await conversationsStore.forkConversation(message.id, options);
}
});
const filteredMessages = currentConfig.showSystemMessage
? messages
: messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
-
// Build display entries, grouping agentic sessions into single entries.
// An agentic session = assistant(with tool_calls) → tool → assistant → tool → ... → assistant(final)
const result: Array<{
if (msg.role === MessageRole.TOOL) continue;
const toolMessages: DatabaseMessage[] = [];
+
if (msg.role === MessageRole.ASSISTANT && hasAgenticContent(msg)) {
let j = i + 1;
}
const siblingInfo = siblingInfoByMessageId.get(msg.id) ?? {
+ currentIndex: 0,
message: msg,
siblingIds: [msg.id],
- currentIndex: 0,
totalSiblings: 1
};
result.push({
- message: msg,
- toolMessages,
isLastAssistantMessage: false,
isLastUserMessage: false,
+ message: msg,
nextAssistantMessage: null,
- siblingInfo
+ siblingInfo,
+ toolMessages
});
}
let lastAssistantIdx = -1;
+
for (let i = result.length - 1; i >= 0; i--) {
if (result[i].message.role === MessageRole.ASSISTANT) {
result[i].isLastAssistantMessage = true;
lastAssistantIdx = i;
+
break;
}
}
for (let j = i + 1; j < result.length; j++) {
if (result[j].message.role === MessageRole.ASSISTANT) {
result[i].nextAssistantMessage = result[j].message;
+
break;
}
}
</script>
<div>
- {#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
+ {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
{message}
<script lang="ts">
+ import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
+ import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
+ import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import { page } from '$app/state';
import {
- ChatScreenForm,
ChatMessages,
ChatScreenDragOverlay,
+ ChatScreenForm,
+ ChatScreenServerError,
ChatScreenStreamResumeStatus,
- ServerLoadingSplash,
- ChatScreenServerError
+ ServerLoadingSplash
} from '$lib/components/app';
+ import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
- import { device } from '$lib/stores/device.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
import {
chatStore,
errorDialog,
- isLoading,
isChatStreaming,
- isEditing
+ isEditing,
+ isLoading
} from '$lib/stores/chat.svelte';
import {
- conversationsStore,
+ activeConversation,
activeMessages,
- activeConversation
+ conversationsStore
} from '$lib/stores/conversations.svelte';
+ import { device } from '$lib/stores/device.svelte';
+ import { serverError, serverLoading } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { serverLoading, serverError } from '$lib/stores/server.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onDestroy, onMount, tick } from 'svelte';
- import ChatScreenGreeting from './ChatScreenGreeting.svelte';
- import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
- import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
- import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
let { showCenteredEmpty = false } = $props();
let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!isMobile.current) return '1rem';
+
if (device.isStandalone) return '1.5rem';
+
if (device.isIOSSafari) return '0.25rem';
+
return '0.5rem';
});
const scroll = useChatScreenScroll(autoScroll);
const activeModel = useChatScreenActiveModel();
const fileUpload = useChatScreenFileUpload({
+ activeModelId: () => activeModel.activeModelId,
capabilities: () => ({
- hasVision: activeModel.hasVisionModality,
hasAudio: activeModel.hasAudioModality,
- hasVideo: activeModel.hasVideoModality
- }),
- activeModelId: () => activeModel.activeModelId
+ hasVideo: activeModel.hasVideoModality,
+ hasVision: activeModel.hasVisionModality
+ })
});
const dragAndDrop = useChatScreenDragAndDrop({
onDrop: fileUpload.handleFileUpload
if (!isMobile.current) return;
const container = scroll.chatScrollContainer;
+
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.clientHeight - container.scrollTop;
+
isMobileUserScrolledUp = distanceFromBottom > 300;
}
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
showEmptyFileDialog = true;
+
if (files) {
const emptyFileNamesSet = new Set(result.emptyFiles);
+
fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
(file) => !emptyFileNamesSet.has(file.name)
);
}
+
return false;
}
handleSendLikeScroll();
await chatStore.sendMessage(message, result?.extras);
+
return true;
}
// height settles, bailing out on user scroll or conversation change.
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
+
const id = activeConversation()?.id ?? null;
+
if (!id || id === lastScrolledConversationId) return;
+
lastScrolledConversationId = id;
await tick();
autoScroll.scrollToBottom();
const container = scroll.chatScrollContainer;
+
if (!container) return;
+
const started = performance.now();
+
let stableFrames = 0;
let lastHeight = container.scrollHeight;
+
const settle = () => {
if (autoScroll.userScrolledUp) return;
+
if (activeConversation()?.id !== id) return;
+
autoScroll.scrollToBottom();
const height = container.scrollHeight;
+
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
lastHeight = height;
+
if (stableFrames >= LANDING_STABLE_FRAMES) return;
+
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
+
requestAnimationFrame(settle);
};
+
requestAnimationFrame(settle);
}
setTimeout(() => {
const container = scroll.chatScrollContainer;
+
if (!container) return;
const lastUserBubble = container.querySelector(
const baseHeight = container.scrollHeight - innerHeight;
container.scrollTo({
- top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight,
- behavior: 'smooth'
+ behavior: 'smooth',
+ top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
});
} else if (lastUserBubble) {
// On desktop, place the last user message near the top of the viewport
const topPadding = 24;
const bubbleRect = lastUserBubble.getBoundingClientRect();
+
container.scrollTo({
- top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding),
- behavior: 'smooth'
+ behavior: 'smooth',
+ top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
});
} else {
autoScroll.scrollToBottom();
if (draft.message || draft.files.length > 0) {
chatStore.savePendingDraft(draft.message, draft.files);
}
+
await chatStore.addSystemPrompt();
}
$effect(() => {
const shouldDisableAutoScroll =
config().disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
+
autoScroll.setDisabled(shouldDisableAutoScroll);
+
if (!shouldDisableAutoScroll) {
autoScroll.enable();
}
onMount(() => {
const pendingDraft = chatStore.consumePendingDraft();
+
if (pendingDraft) {
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
onscroll={(e) => {
scroll.handleScroll(e);
handleMobileScroll();
+
if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
mobileScrollDownHint = false;
}
onclick={() => {
mobileScrollDownHint = false;
scroll.chatScrollContainer?.scrollTo({
- top: scroll.chatScrollContainer.scrollHeight,
- behavior: 'smooth'
+ behavior: 'smooth',
+ top: scroll.chatScrollContainer.scrollHeight
});
}}
/>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ArrowDown } from '@lucide/svelte';
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
let { onclick }: { onclick: (e?: MouseEvent) => void } = $props();
</script>
<script lang="ts">
import { Trash2 } from '@lucide/svelte';
- import { ErrorDialogType } from '$lib/enums';
import {
DialogChatError,
DialogConfirmation,
DialogEmptyFileAlert,
DialogFileUploadError
} from '$lib/components/app';
+ import { ErrorDialogType } from '$lib/enums';
let {
- showDeleteDialog,
- handleDeleteConfirm,
- showEmptyFileDialog,
- emptyFileNames,
activeErrorDialog,
+ emptyFileNames,
+ fileUpload,
+ handleDeleteConfirm,
handleErrorDialogOpenChange,
- fileUpload
+ showDeleteDialog,
+ showEmptyFileDialog
} = $props();
</script>
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
+ import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { onMount } from 'svelte';
- import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
interface Props {
class?: string;
if (!formWrapperEl) return;
const formEl = formWrapperEl.querySelector('form') as HTMLElement | null;
+
if (!formEl) return;
const updateHeight = () => {
const height = Math.round(formEl.getBoundingClientRect().height);
+
document.documentElement.style.setProperty('--chat-form-height', `${height}px`);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
+
resizeObserver.observe(formEl);
return () => {
const { clearDraft } = useDraftMessages({
getChatId: () => chatId,
- getMessage: () => message,
getFiles: () => uploadedFiles,
- setMessage: (m) => (message = m),
+ getInitialMessage: () => initialMessage,
+ getMessage: () => message,
setFiles: (f) => (uploadedFiles = f),
- getInitialMessage: () => initialMessage
+ setMessage: (m) => (message = m)
});
function handleFilesAdd(files: File[]) {
}
function handleSystemPromptClick() {
- onSystemPromptAdd?.({ message, files: uploadedFiles });
+ onSystemPromptAdd?.({ files: uploadedFiles, message });
}
function handleUploadedFileRemove(fileId: string) {
// message editor opened just before a navigation)
function focusFormUnlessCaptured() {
const active = document.activeElement;
+
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return;
+
chatFormRef?.focus();
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
let hasError = $derived(!!serverError());
<script lang="ts">
- import { chatStore } from '$lib/stores/chat.svelte';
- import { StreamConnectionState } from '$lib/enums';
import { Loader2 } from '@lucide/svelte';
+ import { StreamConnectionState } from '$lib/enums';
+ import { chatStore } from '$lib/stores/chat.svelte';
let state = $derived(chatStore.streamConnectionState);
</script>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { cn } from '$lib/components/ui/utils';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
}
let {
- open = $bindable(false),
+ children,
class: className = '',
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
- title = '',
- titleSnippet,
- subtitle,
- shimmerTitle = false,
onToggle,
- children
+ open = $bindable(false),
+ shimmerTitle = false,
+ subtitle,
+ title = '',
+ titleSnippet
}: Props = $props();
function hideBrokenIcon(event: Event) {
}
let {
- open = $bindable(false),
+ children,
class: className = '',
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
- title = '',
- titleSnippet,
- subtitle,
- shimmerTitle = false,
onToggle,
- children
+ open = $bindable(false),
+ shimmerTitle = false,
+ subtitle,
+ title = '',
+ titleSnippet
}: Props = $props();
function hideBrokenIcon(event: Event) {
<script lang="ts">
- import { remark } from 'remark';
- import remarkBreaks from 'remark-breaks';
- import remarkGfm from 'remark-gfm';
- import remarkMath from 'remark-math';
- import rehypeHighlight from 'rehype-highlight';
- import { all as lowlightAll } from 'lowlight';
- import remarkRehype from 'remark-rehype';
- import rehypeKatex from 'rehype-katex';
- import rehypeStringify from 'rehype-stringify';
- import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
- import type { Root as MdastRoot } from 'mdast';
- import { browser } from '$app/environment';
- import { onDestroy, tick } from 'svelte';
- import { SvelteMap } from 'svelte/reactivity';
- import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
- import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
- import { rehypeFileBadge } from './plugins/rehype/file-badge';
+ import '$styles/katex-custom.scss';
+ import {
+ getCodeInfoFromTarget,
+ getHastNodeId,
+ getMdastNodeHash,
+ isAppendMode
+ } from './markdown-utils';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
+ import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
- import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
- import { rehypeSvgPre } from './plugins/rehype/svg-pre';
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
- import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
+ import { rehypeFileBadge } from './plugins/rehype/file-badge';
+ import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
+ import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
+ import { rehypeSvgPre } from './plugins/rehype/svg-pre';
+ import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
+ import { browser } from '$app/environment';
import {
- getHastNodeId,
- getMdastNodeHash,
- isAppendMode,
- getCodeInfoFromTarget
- } from './markdown-utils';
- import {
- preprocessLaTeX,
- getImageErrorFallbackHtml,
- copyCodeToClipboard,
- copyToClipboard,
- splitGluedClosingCodeFences
- } from '$lib/utils';
+ ActionIconCopyToClipboard,
+ CodeBlockActions,
+ DialogCodePreview,
+ DialogMermaidPreview
+ } from '$lib/components/app';
import {
- IMAGE_NOT_ERROR_BOUND_SELECTOR,
- DATA_ERROR_BOUND_ATTR,
- DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING,
- SETTINGS_KEYS,
CODE_BLOCK_HEADER_CLASS,
- MERMAID_WRAPPER_CLASS,
+ DATA_ERROR_BOUND_ATTR,
+ DATA_ERROR_HANDLED_ATTR,
+ DIAGRAM_VIEW_MODE_ATTR,
+ DIAGRAM_VIEW_RENDERED,
+ DIAGRAM_VIEW_SOURCE,
+ IMAGE_NOT_ERROR_BOUND_SELECTOR,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
- MERMAID_SYNTAX_ATTR,
MERMAID_RENDERED_ATTR,
- SVG_WRAPPER_CLASS,
+ MERMAID_SYNTAX_ATTR,
+ MERMAID_WRAPPER_CLASS,
+ SETTINGS_KEYS,
SVG_BLOCK_CLASS,
+ SVG_INLINE_SHADOW_STYLE,
SVG_LANGUAGE,
- XML_LANGUAGE,
- SVG_TAG_PREFIX,
- SVG_SOURCE_ATTR,
SVG_RENDERED_ATTR,
- SVG_INLINE_SHADOW_STYLE,
+ SVG_SOURCE_ATTR,
+ SVG_TAG_PREFIX,
+ SVG_WRAPPER_CLASS,
TOGGLE_SOURCE_BTN_CLASS,
- DIAGRAM_VIEW_MODE_ATTR,
- DIAGRAM_VIEW_RENDERED,
- DIAGRAM_VIEW_SOURCE
+ XML_LANGUAGE
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
- import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
+ import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import type { DatabaseMessageExtra } from '$lib/types/database';
+ import {
+ copyCodeToClipboard,
+ copyToClipboard,
+ getImageErrorFallbackHtml,
+ preprocessLaTeX,
+ splitGluedClosingCodeFences
+ } from '$lib/utils';
+ import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
- import '$styles/katex-custom.scss';
- import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import { all as lowlightAll } from 'lowlight';
+ import type { Root as MdastRoot } from 'mdast';
import { mode } from 'mode-watcher';
- import {
- CodeBlockActions,
- DialogCodePreview,
- DialogMermaidPreview,
- ActionIconCopyToClipboard
- } from '$lib/components/app';
- import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
- import type { DatabaseMessageExtra } from '$lib/types/database';
- import { config } from '$lib/stores/settings.svelte';
+ import rehypeHighlight from 'rehype-highlight';
+ import rehypeKatex from 'rehype-katex';
+ import rehypeStringify from 'rehype-stringify';
+ import { remark } from 'remark';
+ import remarkBreaks from 'remark-breaks';
+ import remarkGfm from 'remark-gfm';
+ import remarkMath from 'remark-math';
+ import remarkRehype from 'remark-rehype';
+ import { onDestroy, tick } from 'svelte';
+ import { SvelteMap } from 'svelte/reactivity';
interface Props {
attachments?: DatabaseMessageExtra[];
contentHash?: string;
}
- let { content, attachments, class: className = '', disableMath = false }: Props = $props();
+ let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
const streamingSvgCode = $derived.by(() => {
const block = incompleteCodeBlock;
+
if (!block) return null;
+
if (block.language === SVG_LANGUAGE) return block.code;
+
if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
return block.code;
+
return null;
});
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
return proc
.use(rehypeHighlight, {
- languages: lowlightAll,
- aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
+ aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
+ languages: lowlightAll
}) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
+
existingTheme?.remove();
}
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
+
existingTheme?.remove();
const style = document.createElement('style');
+
style.id = themeStyleId;
style.textContent = isDark ? githubDarkCss : githubLightCss;
index: number
): Promise<{ html: string; hash: string }> {
const hash = getMdastNodeHash(node, index);
-
const cached = transformCache.get(hash);
+
if (cached) {
- return { html: cached, hash };
+ return { hash, html: cached };
}
- const singleNodeRoot = { type: 'root', children: [node] };
+ const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
transformCache.set(hash, html);
- return { html, hash };
+ return { hash, html };
}
/**
unstableBlockHtml = '';
incompleteCodeBlock = null;
previousContent = '';
+
return;
}
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const nextBlocks: MarkdownBlock[] = [];
-
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(prefixMarkdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
}
// Transform this block (with caching)
- const { html, hash } = await transformMdastNode(processorInstance, child, index);
+ const { hash, html } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
- nextBlocks.push({ id, html, contentHash: hash });
+ nextBlocks.push({ contentHash: hash, html, id });
}
renderedBlocks = nextBlocks;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const stableCount = Math.max(mdastChildren.length - 1, 0);
const nextBlocks: MarkdownBlock[] = [];
-
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(markdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
if (appendMode && index < previousBlockCount) {
const prevBlock = renderedBlocks[index];
const currentHash = getMdastNodeHash(child, index);
+
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
}
// Transform this block (with caching)
- const { html, hash } = await transformMdastNode(processorInstance, child, index);
+ const { hash, html } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
- nextBlocks.push({ id, html, contentHash: hash });
+ nextBlocks.push({ contentHash: hash, html, id });
}
let unstableHtml = '';
if (mdastChildren.length > stableCount) {
const unstableChild = mdastChildren[stableCount];
- const singleNodeRoot = { type: 'root', children: [unstableChild] };
+ const singleNodeRoot = { children: [unstableChild], type: 'root' };
const transformedRoot = (await processorInstance.run(
singleNodeRoot as MdastRoot
)) as HastRoot;
*/
async function handleMermaidClick(event: MouseEvent) {
const target = event.target as HTMLElement;
-
// Toggle a diagram block between its rendered view and its source view.
// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
+
if (toggleBtn) {
event.preventDefault();
event.stopPropagation();
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
+
if (!wrapper) return;
const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
+
wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
toggleBtn.setAttribute('aria-pressed', String(!isSource));
+
return;
}
if (copyBtn || previewBtn) {
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
+
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
+
if (!preElement) return;
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
} catch (error) {
console.error('Failed to copy mermaid syntax:', error);
}
+
return;
}
event.preventDefault();
event.stopPropagation();
const svg = preElement.querySelector('svg');
+
if (!svg) return;
+
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
+
return;
}
}
if (svgCopyBtn || svgPreviewBtn) {
const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
+
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
);
+
if (!preElement) return;
if (svgCopyBtn) {
} catch (error) {
console.error('Failed to copy svg source:', error);
}
+
return;
}
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
svgPreviewLive = false;
mermaidPreviewOpen = true;
+
return;
}
}
// Open preview when clicking the svg block itself. A final block carries its
// source, a streaming block does not and is mirrored live into the dialog.
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
+
if (svgEl) {
const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
+
if (source !== null) {
mermaidPreviewSvgHtml = sanitizeSvg(source);
svgPreviewLive = false;
} else {
svgPreviewLive = true;
}
+
mermaidPreviewOpen = true;
+
return;
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
+
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
+
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
*/
function handleMermaidPreviewOpenChange(open: boolean) {
mermaidPreviewOpen = open;
+
if (!open) {
mermaidPreviewSvgHtml = '';
svgPreviewLive = false;
const nodes = containerRef.querySelectorAll(
`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
);
+
if (nodes.length === 0) return;
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
// Read mode before await so Svelte tracks it reactively.
const isDark = mode.current === ColorMode.DARK;
-
// lazy load the mermaid dependecy only when needed to reduce bundle size.
const { default: mermaid } = await import('mermaid');
mermaid.initialize({
- startOnLoad: false,
- theme: isDark ? 'dark' : 'default',
- securityLevel: 'strict',
flowchart: {
- useMaxWidth: false,
- htmlLabels: true
- },
- sequence: {
+ htmlLabels: true,
useMaxWidth: false
},
gantt: {
useMaxWidth: false
- }
+ },
+ securityLevel: 'strict',
+ sequence: {
+ useMaxWidth: false
+ },
+ startOnLoad: false,
+ theme: isDark ? 'dark' : 'default'
});
try {
const nodes = containerRef.querySelectorAll<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
);
+
if (nodes.length === 0) return;
nodes.forEach((node) => {
if (clean) {
node.textContent = '';
const host = document.createElement('div');
+
node.appendChild(host);
mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
}
*/
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
+
if (!img || !img.src) return;
// Don't handle data URLs or already-handled images
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
)
return;
+
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
const src = img.src;
// Create fallback element
const fallback = document.createElement('div');
+
fallback.className = 'image-load-error';
fallback.innerHTML = getImageErrorFallbackHtml(src);
try {
while (pendingMarkdown !== null) {
const nextMarkdown = pendingMarkdown;
+
pendingMarkdown = null;
await processMarkdown(nextMarkdown);
* Uses dependency injection pattern to avoid direct component state access.
*/
+import { MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR, MERMAID_WRAPPER_CLASS } from '$lib/constants';
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
-import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
export interface PreviewState {
previewDialogOpen: boolean;
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
+
if (!target) return;
const wrapper = target.closest('.code-block-wrapper');
+
if (!wrapper) return;
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+
if (!codeElement) return;
const rawCode = codeElement.textContent ?? '';
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
+
if (!target) return;
const wrapper = target.closest('.code-block-wrapper');
+
if (!wrapper) return;
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+
if (!codeElement) return;
const rawCode = codeElement.textContent ?? '';
export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
return async function handleMermaidClick(event: MouseEvent) {
const target = event.target as HTMLElement;
-
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
if (copyBtn || previewBtn) {
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
+
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
+
if (!preElement) return;
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
} catch (error) {
console.error('Failed to copy mermaid syntax:', error);
}
+
return;
}
event.preventDefault();
event.stopPropagation();
const svg = preElement.querySelector('svg');
+
if (!svg) return;
+
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
mermaidState.setMermaidPreviewOpen(true);
+
return;
}
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
+
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
+
if (!svg) return;
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPreviewState) {
return function handleMermaidPreviewOpenChange(open: boolean) {
mermaidState.setMermaidPreviewOpen(open);
+
if (!open) {
mermaidState.setMermaidPreviewSvgHtml('');
}
) {
return async function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
+
if (!img) return;
const blockId = img.closest('[data-block-id]')?.getAttribute('data-block-id');
+
if (!blockId) return;
const block = renderedBlocksState.renderedBlocks.find((b) => b.id === blockId);
+
if (!block) return;
// Skip if already handled
if (img.dataset[DATA_ERROR_BOUND_ATTR] === BOOL_TRUE_STRING) return;
+
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
// Get the fallback HTML and replace the image
<span class="image-error-icon">⚠️</span>
<span class="image-error-text">Failed to load image</span>
</div>`;
-
// Replace the img element with fallback in the block's HTML
const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
if (src === img.src) {
return fallbackHtml.replace('data-original-src=""', `data-original-src="${src}"`);
}
+
return match;
});
-
// Update the block
const newBlocks = renderedBlocksState.renderedBlocks.map((b) =>
b.id === blockId ? { ...b, html: newHtml } : b
);
+
renderedBlocksState.setRenderedBlocks(newBlocks);
};
}
if (!wrapper) {
console.error('No wrapper found');
+
return null;
}
if (!codeElement) {
console.error('No code element found in wrapper');
+
return null;
}
const rawCode = codeElement.textContent ?? '';
-
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
const language = languageLabel?.textContent?.trim() || 'text';
- return { rawCode, language };
+ return { language, rawCode };
}
* Contains common HAST element creation functions to avoid code duplication.
*/
-import type { Element, ElementContent } from 'hast';
import {
- CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_ACTIONS_CLASS,
+ CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
+ CODE_ICON_SVG,
CODE_LANGUAGE_CLASS,
COPY_CODE_BTN_CLASS,
- PREVIEW_CODE_BTN_CLASS,
- TOGGLE_SOURCE_BTN_CLASS,
- DIAGRAM_SOURCE_CLASS,
- RELATIVE_CLASS,
COPY_ICON_SVG,
+ DIAGRAM_SOURCE_CLASS,
+ PREVIEW_CODE_BTN_CLASS,
PREVIEW_ICON_SVG,
- CODE_ICON_SVG
+ RELATIVE_CLASS,
+ TOGGLE_SOURCE_BTN_CLASS
} from '$lib/constants';
+import type { Element, ElementContent } from 'hast';
export interface BlockIdGenerator {
(id: number): string;
*/
export function createIconElement(svg: string): Element {
return {
- type: 'element',
- tagName: 'span',
+ children: [{ type: 'raw', value: svg } as unknown as ElementContent],
properties: {},
- children: [{ type: 'raw', value: svg } as unknown as ElementContent]
+ tagName: 'span',
+ type: 'element'
};
}
extraProperties: Record<string, string> = {}
): Element {
return {
- type: 'element',
- tagName: 'button',
+ children: [createIconElement(iconSvg)],
properties: {
className: [className],
[idAttribute]: id,
type: 'button',
...extraProperties
},
- children: [createIconElement(iconSvg)]
+ tagName: 'button',
+ type: 'element'
};
}
language: string
): Element {
const code: Element = codeElement ?? {
- type: 'element',
- tagName: 'code',
+ children: [{ type: 'text', value: source }],
properties: { className: ['hljs', `language-${language}`] },
- children: [{ type: 'text', value: source }]
+ tagName: 'code',
+ type: 'element'
};
+
return {
- type: 'element',
- tagName: 'div',
- properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
children: [
{
- type: 'element',
- tagName: 'pre',
+ children: [code],
properties: {},
- children: [code]
+ tagName: 'pre',
+ type: 'element'
}
- ]
+ ],
+ properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
+ tagName: 'div',
+ type: 'element'
};
}
languageClassName: string = CODE_LANGUAGE_CLASS
): Element {
return {
- type: 'element',
- tagName: 'div',
- properties: { className: [CODE_BLOCK_HEADER_CLASS] },
children: [
{
- type: 'element',
- tagName: 'span',
+ children: [{ type: 'text', value: language }],
properties: { className: [languageClassName] },
- children: [{ type: 'text', value: language }]
+ tagName: 'span',
+ type: 'element'
},
{
- type: 'element',
- tagName: 'div',
+ children: actions,
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
- children: actions
+ tagName: 'div',
+ type: 'element'
}
- ]
+ ],
+ properties: { className: [CODE_BLOCK_HEADER_CLASS] },
+ tagName: 'div',
+ type: 'element'
};
}
*/
export function createScrollContainer(preElement: Element, scrollContainerClass: string): Element {
return {
- type: 'element',
- tagName: 'div',
+ children: [preElement],
properties: { className: [scrollContainerClass] },
- children: [preElement]
+ tagName: 'div',
+ type: 'element'
};
}
extraChildren: Element[] = []
): Element {
return {
- type: 'element',
- tagName: 'div',
+ children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren],
properties: {
className: [wrapperClass, RELATIVE_CLASS],
...additionalAttributes
} as Element['properties'],
- children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren]
+ tagName: 'div',
+ type: 'element'
};
}
if (typeof window !== 'undefined') {
const idx = window[windowKey] as number | undefined;
const next = (idx ?? 0) + 1;
+
(window as unknown as Record<string, number>)[windowKey] = next;
+
return `${prefix}-${next}`;
}
+
// Fallback for SSR - use timestamp + random
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
* avoiding the need to stringify and re-parse HTML.
*/
-import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent } from 'hast';
-import { visit } from 'unist-util-visit';
-import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
createWrapper,
generateBlockId
} from './code-block-utils';
+import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
declare global {
interface Window {
function extractLanguage(codeElement: Element): string {
const className = codeElement.properties?.className;
+
if (!Array.isArray(className)) return 'text';
for (const cls of className) {
* ensuring external links open in new tabs safely.
*/
+import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
/**
* avoiding the need to stringify and re-parse HTML.
*/
-import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent } from 'hast';
-import { visit } from 'unist-util-visit';
-import {
- MERMAID_WRAPPER_CLASS,
- MERMAID_SCROLL_CONTAINER_CLASS,
- MERMAID_BLOCK_CLASS,
- MERMAID_LANGUAGE,
- MERMAID_SYNTAX_ATTR,
- MERMAID_ID_ATTR,
- DIAGRAM_VIEW_MODE_ATTR,
- DIAGRAM_VIEW_RENDERED
-} from '$lib/constants';
-import type { DiagramPreData } from './pre-transform';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
- createToggleSourceButton,
createSourceView,
+ createToggleSourceButton,
createWrapper,
generateBlockId
} from './code-block-utils';
+import type { DiagramPreData } from './pre-transform';
+import {
+ DIAGRAM_VIEW_MODE_ATTR,
+ DIAGRAM_VIEW_RENDERED,
+ MERMAID_BLOCK_CLASS,
+ MERMAID_ID_ATTR,
+ MERMAID_LANGUAGE,
+ MERMAID_SCROLL_CONTAINER_CLASS,
+ MERMAID_SYNTAX_ATTR,
+ MERMAID_WRAPPER_CLASS
+} from '$lib/constants';
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
declare global {
interface Window {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
+
if (!Array.isArray(className)) return;
const isMermaid = className.some(
if (!isMermaid) return;
const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
-
// Extract the mermaid syntax (text content of the pre element)
const diagramText = node.children
.map((child) => {
if (child.type === 'text') return child.value;
+
return '';
})
.join('');
// Store the mermaid syntax in data attribute for copy functionality
node.properties = {
...node.properties,
- [MERMAID_SYNTAX_ATTR]: diagramText,
- [MERMAID_ID_ATTR]: mermaidId
+ [MERMAID_ID_ATTR]: mermaidId,
+ [MERMAID_SYNTAX_ATTR]: diagramText
};
const actions = [
createToggleSourceButton(mermaidId, MERMAID_ID_ATTR, 'Toggle mermaid source'),
createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
];
-
const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
const sourceView = createSourceView(preservedCode, diagramText, MERMAID_LANGUAGE);
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
{
- [MERMAID_ID_ATTR]: mermaidId,
- [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
+ [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
+ [MERMAID_ID_ATTR]: mermaidId
},
[sourceView]
);
* Operates directly on the HAST tree and reuses the shared code-block builders.
*/
-import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent } from 'hast';
-import { visit } from 'unist-util-visit';
-import {
- SVG_WRAPPER_CLASS,
- SVG_SCROLL_CONTAINER_CLASS,
- SVG_BLOCK_CLASS,
- SVG_LANGUAGE,
- SVG_SOURCE_ATTR,
- SVG_ID_ATTR,
- DIAGRAM_VIEW_MODE_ATTR,
- DIAGRAM_VIEW_RENDERED
-} from '$lib/constants';
-import type { DiagramPreData } from './pre-transform';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
- createToggleSourceButton,
createSourceView,
+ createToggleSourceButton,
createWrapper,
generateBlockId
} from './code-block-utils';
+import type { DiagramPreData } from './pre-transform';
+import {
+ DIAGRAM_VIEW_MODE_ATTR,
+ DIAGRAM_VIEW_RENDERED,
+ SVG_BLOCK_CLASS,
+ SVG_ID_ATTR,
+ SVG_LANGUAGE,
+ SVG_SCROLL_CONTAINER_CLASS,
+ SVG_SOURCE_ATTR,
+ SVG_WRAPPER_CLASS
+} from '$lib/constants';
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
declare global {
interface Window {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
+
if (!Array.isArray(className)) return;
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
if (!isSvg) return;
const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
-
// Extract the svg source (text content of the pre element)
const svgSource = node.children
.map((child) => {
if (child.type === 'text') return child.value;
+
return '';
})
.join('');
// Store the svg source in data attribute for copy and render
node.properties = {
...node.properties,
- [SVG_SOURCE_ATTR]: svgSource,
- [SVG_ID_ATTR]: svgId
+ [SVG_ID_ATTR]: svgId,
+ [SVG_SOURCE_ATTR]: svgSource
};
const actions = [
createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
];
-
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
{
- [SVG_ID_ATTR]: svgId,
- [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
+ [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
+ [SVG_ID_ATTR]: svgId
},
[sourceView]
);
* no tab stop); the full path stays available on `title`.
*/
-import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import {
FILE_URI_PREFIX,
MENTION_BADGE_CLASSNAME,
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
+import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
+import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
// Trailing path separators mark a directory and are kept out of the label.
function decodeHrefPath(href: string): string {
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
+
return decodeFileLinkPath(stripped);
}
const decoded = decodeHrefPath(href);
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
+
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
// icon, matching the convention the mention picker inserts with.
function iconElement(href: string): Element {
return {
- type: 'element',
- tagName: 'svg',
+ children: getMentionBadgeIconPaths(href).map((d) => ({
+ children: [],
+ properties: { d },
+ tagName: 'path',
+ type: 'element'
+ })),
properties: {
...MENTION_BADGE_SVG_ATTRIBUTES,
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
},
- children: getMentionBadgeIconPaths(href).map((d) => ({
- type: 'element',
- tagName: 'path',
- properties: { d },
- children: []
- }))
+ tagName: 'svg',
+ type: 'element'
};
}
node.children = [
iconElement(href),
{
- type: 'element',
- tagName: 'span',
- properties: { className: ['shrink-0', 'truncate'] },
children: [
{
type: 'text',
toolsStore.serverHome
)
}
- ]
+ ],
+ properties: { className: ['shrink-0', 'truncate'] },
+ tagName: 'span',
+ type: 'element'
}
];
});
+import type { Element, ElementContent, Root, Text } from 'hast';
import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
/**
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
+
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
+
return '';
}
if (!codeElement) return;
const className = codeElement.properties?.className;
+
if (!Array.isArray(className)) return;
const matches = className.some(
if (contentGuard && !contentGuard(text)) return;
const pre: Element = {
- type: 'element',
- tagName: 'pre',
- properties: {
- className: [targetClass]
- },
children: [{ type: 'text', value: text } as Text],
// Keep the highlighted code element so the block can offer a source
// view that matches the app code blocks without re highlighting.
- data: { sourceCode: codeElement } satisfies DiagramPreData
+ data: { sourceCode: codeElement } satisfies DiagramPreData,
+ properties: {
+ className: [targetClass]
+ },
+ tagName: 'pre',
+ type: 'element'
};
(parent.children as ElementContent[])[index] = pre;
* (including those not in a predefined list) receive the attribute.
*/
+import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
/**
+import { AttachmentType, UrlProtocol } from '$lib/enums';
+import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
import type { Root as HastRoot } from 'hast';
import { visit } from 'unist-util-visit';
-import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
-import { AttachmentType, UrlProtocol } from '$lib/enums';
/**
* Rehype plugin to resolve attachment image sources.
import { createPreTransform } from './pre-transform';
-import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
+import { SVG_BLOCK_CLASS, SVG_LANGUAGE, SVG_TAG_PREFIX, XML_LANGUAGE } from '$lib/constants';
/**
* Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
* // With this plugin: <br> becomes line break, <ul> becomes actual list
*/
-import type { Plugin } from 'unified';
+import { BR_PATTERN, LI_PATTERN, LIST_PATTERN } from '$lib/constants';
import type { Element, ElementContent, Root, Text } from 'hast';
+import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { visitParents } from 'unist-util-visit-parents';
-import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants';
/**
* Expands text containing `<br>` tags into an array of text nodes and br elements.
*/
function expandBrTags(value: string): ElementContent[] {
const matches = [...value.matchAll(BR_PATTERN)];
+
if (!matches.length) return [{ type: 'text', value } as Text];
const result: ElementContent[] = [];
+
let cursor = 0;
for (const m of matches) {
if (m.index! > cursor) {
result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text);
}
- result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element);
+
+ result.push({ children: [], properties: {}, tagName: 'br', type: 'element' } as Element);
cursor = m.index! + m[0].length;
}
*/
function parseList(value: string): Element | null {
const match = value.trim().match(LIST_PATTERN);
+
if (!match) return null;
const body = match[1];
const items: ElementContent[] = [];
+
let cursor = 0;
for (const liMatch of body.matchAll(LI_PATTERN)) {
if (body.slice(cursor, liMatch.index!).trim()) return null;
items.push({
- type: 'element',
- tagName: 'li',
+ children: expandBrTags(liMatch[1] ?? ''),
properties: {},
- children: expandBrTags(liMatch[1] ?? '')
+ tagName: 'li',
+ type: 'element'
} as Element);
cursor = liMatch.index! + liMatch[0].length;
// Reject if no items found or trailing garbage exists
if (!items.length || body.slice(cursor).trim()) return null;
- return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element;
+ return { children: items, properties: {}, tagName: 'ul', type: 'element' } as Element;
}
/**
function processCell(cell: Element) {
visitParents(cell, 'text', (textNode: Text, ancestors) => {
const parent = ancestors[ancestors.length - 1];
+
if (!parent || parent.type !== 'element') return;
const parentEl = parent as Element;
const siblings = parentEl.children as ElementContent[];
const startIndex = siblings.indexOf(textNode as ElementContent);
+
if (startIndex === -1) return;
// Combine consecutive text nodes and <br> elements into one string
for (let i = startIndex; i < siblings.length; i++) {
const sib = siblings[i];
+
if (sib.type === 'text') {
combined += (sib as Text).value;
endIndex = i;
// Try parsing as list first (replaces entire combined range)
const list = parseList(combined);
+
if (list) {
siblings.splice(startIndex, endIndex - startIndex + 1, list);
+
return;
}
// Otherwise, just expand <br> tags in this text node
const expanded = expandBrTags(textNode.value);
+
if (expanded.length !== 1 || expanded[0] !== textNode) {
siblings.splice(startIndex, 1, ...expanded);
}
+import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
+import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
-import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
-import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
/**
* remark plugin that rewrites raw HTML nodes into plain-text equivalents.
if (char === ' ') {
output += NBSP;
index += 1;
+
continue;
}
if (char === '\t') {
output += TAB_AS_SPACES;
index += 1;
+
continue;
}
if (!PHRASE_PARENTS.has(parent.type as string)) {
const paragraph: Paragraph = {
- type: 'paragraph',
children: replacement as Paragraph['children'],
- data: { literalHtml: true }
+ data: { literalHtml: true },
+ type: 'paragraph'
};
-
const siblings = parent.children as unknown as Content[];
+
siblings.splice(index, 1, paragraph as unknown as Content);
if (index > 0) {
<script lang="ts">
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
- import { mountSvgShadow } from '$lib/utils/svg-shadow';
import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
+ import { mountSvgShadow } from '$lib/utils/svg-shadow';
interface Props {
svgHtml: string;
event.preventDefault();
const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
+
scale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
}
// (Svelte 5 wheel listeners are passive by default, making preventDefault() a no-op)
$effect(() => {
const el = containerRef.current;
+
if (!el) return;
function onWheel(e: WheelEvent) {
}
el.addEventListener('wheel', onWheel, { passive: false });
+
return () => el.removeEventListener('wheel', onWheel);
});
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Download } from '@lucide/svelte';
+ import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
- import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
scale: number;
onResetView: () => void;
}
- let { scale, svgHtml, onZoomIn, onZoomOut, onResetView }: Props = $props();
+ let { onResetView, onZoomIn, onZoomOut, scale, svgHtml }: Props = $props();
function downloadSvg() {
if (!svgHtml) return;
+
const blob = new Blob([svgHtml], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+
a.href = url;
a.download = 'diagram.svg';
a.click();
<script lang="ts">
import { browser } from '$app/environment';
- import { mode } from 'mode-watcher';
-
- import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
- import githubLightCss from 'highlight.js/styles/github.css?inline';
- import { ColorMode } from '$lib/enums';
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+ import { ColorMode } from '$lib/enums';
import { highlightCode } from '$lib/utils';
+ import githubLightCss from 'highlight.js/styles/github.css?inline';
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import { mode } from 'mode-watcher';
interface Props {
code: string;
}
let {
+ class: className = '',
code,
language = 'text',
- class: className = '',
maxHeight = '60vh',
maxWidth = '',
streaming = false
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
+
existingThemes.forEach((style) => style.remove());
const style = document.createElement('style');
+
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
function isAtBottom(): boolean {
if (!scrollEl) return false;
+
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
+
// User may scroll between scheduling and paint.
if (scrollEl && !userScrolledUp) {
scrollEl.scrollTop = scrollEl.scrollHeight;
function handleScrollEvent() {
if (!scrollEl) return;
+
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
+
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
void code;
+
if (!streaming || userScrolledUp) return;
+
scrollToBottomOnFrame();
});
if (!streaming || !scrollEl) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
+
observer.observe(scrollEl, {
+ characterData: true,
childList: true,
- subtree: true,
- characterData: true
+ subtree: true
});
return () => observer.disconnect();
<script lang="ts">
- import { Dialog } from 'bits-ui';
import { X } from '@lucide/svelte';
- import * as DialogUI from '$lib/components/ui/dialog';
import { ChatAttachmentsPreview } from '$lib/components/app';
+ import * as DialogUI from '$lib/components/ui/dialog';
import { KeyboardKey } from '$lib/enums';
+ import { Dialog } from 'bits-ui';
interface Props {
open: boolean;
}
let {
- open = $bindable(false),
- uploadedFiles = [],
- attachments = [],
activeModelId,
- previewFocusIndex = 0
+ attachments = [],
+ open = $bindable(false),
+ previewFocusIndex = 0,
+ uploadedFiles = []
}: Props = $props();
function handleClose() {
}
document.addEventListener('keydown', handleKeyDown);
+
return () => document.removeEventListener('keydown', handleKeyDown);
});
</script>
<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { AlertTriangle, TimerOff } from '@lucide/svelte';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { ErrorDialogType } from '$lib/enums';
interface Props {
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
+ let { contextInfo, message, onOpenChange, open = $bindable(), type }: Props = $props();
const isTimeout = $derived(type === ErrorDialogType.TIMEOUT);
const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
<script lang="ts">
- import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
+ import { Dialog as DialogPrimitive } from 'bits-ui';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), code, language, onOpenChange }: Props = $props();
+ let { code, language, onOpenChange, open = $bindable() }: Props = $props();
let iframeRef = $state<HTMLIFrameElement | null>(null);
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import type { Component, Snippet } from 'svelte';
import { KeyboardKey } from '$lib/enums';
+ import type { Component, Snippet } from 'svelte';
interface Props {
open: boolean;
}
let {
- open = $bindable(),
- title,
- description,
- confirmText = 'Confirm',
cancelText = 'Cancel',
- variant = 'default',
+ children,
+ confirmText = 'Confirm',
+ description,
icon,
- onConfirm,
onCancel,
+ onConfirm,
onKeydown,
- children
+ open = $bindable(),
+ title,
+ variant = 'default'
}: Props = $props();
function handleKeydown(event: KeyboardEvent) {
onConfirm();
}
+
onKeydown?.(event);
}
<script lang="ts">
+ import { Pencil } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
- import { Pencil } from '@lucide/svelte';
interface Props {
open: boolean;
}
let {
- open = $bindable(),
currentTitle,
- value = $bindable(''),
+ onCancel,
onConfirm,
- onCancel
+ open = $bindable(),
+ value = $bindable('')
}: Props = $props();
let inputRef = $state<HTMLInputElement | null>(null);
function handleSubmit(event: Event) {
event.preventDefault();
+
if (!canSubmit) return;
+
value = value.trim();
onConfirm();
}
<script lang="ts">
- import * as Dialog from '$lib/components/ui/dialog';
import { ConversationSelection } from '$lib/components/app';
+ import * as Dialog from '$lib/components/ui/dialog';
interface Props {
conversations: DatabaseConversation[];
<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { FileX } from '@lucide/svelte';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
+ let { emptyFiles, onOpenChange, open = $bindable() }: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
<script lang="ts">
+ import { Shield, ShieldOff } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte';
- import { Shield, ShieldOff } from '@lucide/svelte';
let {
- open = $bindable(),
includeSensitiveData = $bindable(false),
onCancel,
- onConfirm
+ onConfirm,
+ open = $bindable()
}: {
open: boolean;
includeSensitiveData: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), fileErrorData, onOpenChange }: Props = $props();
+ let { fileErrorData, onOpenChange, open = $bindable() }: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
<script lang="ts">
- import * as Dialog from '$lib/components/ui/dialog';
import { Download } from '@lucide/svelte';
+ import { ActionIconCopyToClipboard, SyntaxHighlightedCode } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
+ import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
+ import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import {
+ downloadResourceContent,
getLanguageFromFilename,
isCodeResource,
- isImageResource,
- downloadResourceContent
+ isImageResource
} from '$lib/utils';
- import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
- import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
- import type { DatabaseMessageExtraMcpResource } from '$lib/types';
interface Props {
open: boolean;
extra: DatabaseMessageExtraMcpResource;
}
- let { open = $bindable(), onOpenChange, extra }: Props = $props();
+ let { extra, onOpenChange, open = $bindable() }: Props = $props();
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
function getLanguage(): string {
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return MimeTypeIncludes.JSON;
+
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return MimeTypeIncludes.JAVASCRIPT;
+
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return MimeTypeIncludes.TYPESCRIPT;
const name = extra.name || extra.uri || '';
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
- import { toast } from 'svelte-sonner';
- import * as Dialog from '$lib/components/ui/dialog';
+ import { Braces, FolderOpen, Loader2, Plus } from '@lucide/svelte';
+ import {
+ McpResourcePreview,
+ McpResourcesBrowser,
+ McpResourceTemplateForm
+ } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResources,
- mcpTotalResourceCount,
- mcpResourceStore
+ mcpResourceStore,
+ mcpTotalResourceCount
} from '$lib/stores/mcp-resources.svelte';
- import {
- McpResourcesBrowser,
- McpResourcePreview,
- McpResourceTemplateForm
- } from '$lib/components/app';
+ import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
- import type { MCPResourceInfo, MCPResourceContent, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
+ import { toast } from 'svelte-sonner';
interface Props {
open?: boolean;
preSelectedUri?: string;
}
- let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
+ let { onAttach, onOpenChange, open = $bindable(false), preSelectedUri }: Props = $props();
let selectedResources = new SvelteSet<string>();
let lastSelectedUri = $state<string | null>(null);
if (mcpResourceStore.isAttached(templatePreviewUri)) {
toast.info('Resource already attached');
handleOpenChange(false);
+
return;
}
const resourceInfo: MCPResourceInfo = {
- uri: templatePreviewUri,
name: templatePreviewUri.split('/').pop() || templatePreviewUri,
- serverName: selectedTemplate.serverName
+ serverName: selectedTemplate.serverName,
+ uri: templatePreviewUri
};
-
const attachment = mcpResourceStore.addAttachment(resourceInfo);
+
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
toast.success(`Resource attached: ${resourceInfo.name}`);
return allResources.sort((a, b) => {
const aName = getResourceDisplayName(a);
const bName = getResourceDisplayName(b);
+
return aName.localeCompare(bName);
});
}
<!-- Template resolved: show preview -->
<McpResourcePreview
resource={{
- uri: templatePreviewUri ?? '',
name: templatePreviewUri?.split('/').pop() || (templatePreviewUri ?? ''),
- serverName: selectedTemplate?.serverName || ''
+ serverName: selectedTemplate?.serverName || '',
+ uri: templatePreviewUri ?? ''
}}
preloadedContent={templatePreviewContent}
/>
<script lang="ts">
+ import { browser } from '$app/environment';
+ import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
- import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { parseHeadersToArray, uuid, canonicalizeServerUrl } from '$lib/utils';
import {
BEARER_PREFIX,
BOOL_FALSE_STRING,
RECOMMENDED_MCP_SERVERS,
REDACTED_HEADERS
} from '$lib/constants';
- import { browser } from '$app/environment';
import { HealthCheckStatus } from '$lib/enums';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), onOpenChange }: Props = $props();
+ let { onOpenChange, open = $bindable() }: Props = $props();
let newServerUrl = $state('');
let newServerName = $state('');
let selectedRecommendationId = $derived.by(() => {
const url = newServerUrl.trim();
+
if (!url) return null;
+
const targetCanonical = canonicalizeServerUrl(url);
+
return (
RECOMMENDED_MCP_SERVERS.find((rec) => canonicalizeServerUrl(rec.url) === targetCanonical)
?.id ?? null
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
+
try {
new URL(newServerUrl);
// Backward-compatible read: older versions stored a JSON array of dismissed ids.
function readRecommendationsDismissed(): boolean {
if (!browser) return false;
+
const raw = localStorage.getItem(DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY);
if (!raw) return false;
if (raw === BOOL_TRUE_STRING) return true;
+
if (raw === BOOL_FALSE_STRING) return false;
try {
const parsed = JSON.parse(raw);
+
return Array.isArray(parsed) && parsed.length > 0;
} catch {
return false;
const previewId = `${MCP_SERVER_ID_PREFIX}-preview-${run}`;
const timer = setTimeout(async () => {
await mcpStore.runHealthCheck({
- id: previewId,
enabled: false,
- url,
headers: headers || undefined,
+ id: previewId,
+ url,
useProxy
});
newServerUseProxy = false;
newServerWantsAuthorization = false;
}
+
open = value;
onOpenChange?.(value);
}
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
mcpStore.addServer({
- id: newServerId,
- enabled: true,
- url: newServerUrl.trim(),
// A name equal to the autofilled server-reported one is not a
// customization: keep following the automatic label.
displayName:
newServerName.trim() && newServerName.trim() !== nameAutoFilled.trim()
? newServerName.trim()
: undefined,
+ enabled: true,
headers: newServerHeaders.trim() || undefined,
+ id: newServerId,
+ url: newServerUrl.trim(),
useProxy: newServerUseProxy
});
<script lang="ts">
- import * as Dialog from '$lib/components/ui/dialog/index.js';
import { MermaidPreview } from '$lib/components/app/content';
+ import * as Dialog from '$lib/components/ui/dialog/index.js';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), svgHtml, onOpenChange }: Props = $props();
+ let { onOpenChange, open = $bindable(), svgHtml }: Props = $props();
</script>
<Dialog.Root bind:open {onOpenChange}>
<script lang="ts">
+ import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
- import { BadgesModality, ActionIconCopyToClipboard } from '$lib/components/app';
+ import { modelOptions, modelsLoading, modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
- import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
- import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
import type { ApiLlamaCppServerProps } from '$lib/types';
+ import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
interface Props {
open?: boolean;
modelId?: string | null;
}
- let { open = $bindable(), onOpenChange, modelId = null }: Props = $props();
+ let { modelId = null, onOpenChange, open = $bindable() }: Props = $props();
let isRouter = $derived(serverStore.isRouterMode);
if (isRouter && modelId) {
return models.find((m) => m.model === modelId) ?? null;
}
+
return models[0] ?? null;
});
// Get modalities from modelStore using the model ID from the first model
let modalities = $derived.by(() => {
if (!firstModel?.id) return [];
+
return modelsStore.getModelModalitiesArray(firstModel.id);
});
isLoadingRouterProps = false;
});
}
+
if (!open) {
routerModelProps = null;
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { URL_PARAMS } from '$lib/constants';
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { URL_PARAMS } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
- let { open = $bindable(), modelName, availableModels = [], onOpenChange }: Props = $props();
+ let { availableModels = [], modelName, onOpenChange, open = $bindable() }: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
function handleSelectModel(model: string) {
// Build URL with selected model, preserving other params
const url = new URL(page.url);
+
url.searchParams.set(URL_PARAMS.MODEL, model);
handleOpenChange(false);
}
let {
- text,
+ matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30',
query,
- matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
+ text
}: Props = $props();
let segments = $derived(highlightMatch(text, query));
<script lang="ts">
- import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
+ import { fly } from 'svelte/transition';
interface Props {
name: string;
}
let {
- name,
- value = '',
- suggestions = [],
- isLoadingSuggestions = false,
- isAutocompleteActive = false,
autocompleteIndex = 0,
- onInput,
- onKeydown,
+ isAutocompleteActive = false,
+ isLoadingSuggestions = false,
+ name,
onBlur,
onFocus,
- onSelectSuggestion
+ onInput,
+ onKeydown,
+ onSelectSuggestion,
+ suggestions = [],
+ value = ''
}: Props = $props();
</script>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
- transition:fly={{ y: -5, duration: 100 }}
+ transition:fly={{ duration: 100, y: -5 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
<script lang="ts">
- import { tick } from 'svelte';
import { Plus, Trash2 } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
+ import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants';
+ import type { KeyValuePair } from '$lib/types';
import {
autoResizeTextarea,
sanitizeKeyValuePairKey,
sanitizeKeyValuePairValue
} from '$lib/utils';
- import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants';
- import type { KeyValuePair } from '$lib/types';
+ import { tick } from 'svelte';
interface Props {
class?: string;
}
let {
- class: className = '',
- pairs,
- onPairsChange,
- keyPlaceholder = 'Key',
- valuePlaceholder = 'Value',
addButtonLabel = 'Add',
+ class: className = '',
emptyMessage = 'No items configured.',
+ keyPlaceholder = 'Key',
+ onPairsChange,
+ pairs,
sectionLabel,
- sectionLabelOptional = true
+ sectionLabelOptional = true,
+ valuePlaceholder = 'Value'
}: Props = $props();
// Pre-allocate the ref array so `bind:ref={keyInputRefs[index]}` never reads `undefined`
// Capture the target index before mutating so deletions earlier in the
// list can't make keyInputRefs.length drift past the newly-appended row.
const newIndex = pairs.length;
+
onPairsChange([...pairs, { key: '', value: '' }]);
await tick();
keyInputRefs[newIndex]?.focus();
function trimPairKey(index: number, key: string) {
const trimmed = key.trim();
+
if (trimmed === key) return;
const newPairs = [...pairs];
function trimPairValue(index: number, value: string) {
const trimmed = value.trim();
+
if (trimmed === value) return;
const newPairs = [...pairs];
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Input } from '$lib/components/ui/input';
import { Search, X } from '@lucide/svelte';
+ import { Input } from '$lib/components/ui/input';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
autofocus?: boolean;
let {
autofocus,
- value = $bindable(''),
- placeholder = 'Search...',
- onInput,
- onClose,
- onKeyDown,
class: className,
id,
+ isCancelAlwaysVisible = false,
+ onClose,
+ onInput,
+ onKeyDown,
+ placeholder = 'Search...',
ref = $bindable(null),
- isCancelAlwaysVisible = false
+ value = $bindable('')
}: Props = $props();
let showClearButton = $derived(isCancelAlwaysVisible || !!value || !!onClose);
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import McpLogo from './McpLogo.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import { HealthCheckStatus } from '$lib/enums';
- import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
- import McpLogo from './McpLogo.svelte';
interface Props {
class?: string;
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
+
return healthState.status !== HealthCheckStatus.ERROR;
})
);
<script lang="ts">
- import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
- import type { MCPCapabilitiesInfo } from '$lib/types';
+ import { Database, FileText, ListChecks, MessageSquare, Sparkles, Wrench } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
+ import type { MCPCapabilitiesInfo } from '$lib/types';
interface Props {
capabilities?: MCPCapabilitiesInfo;
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import type { MCPConnectionLog } from '$lib/types';
- import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
+ import { formatTime, getMcpLogLevelClass, getMcpLogLevelIcon } from '$lib/utils';
interface Props {
logs: MCPConnectionLog[];
class?: string;
}
- let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
+ let { class: className, connectionTimeMs, defaultExpanded = false, logs }: Props = $props();
let isExpanded = $derived(defaultExpanded);
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
+ import { AlertCircle, Download, FileText, Loader2 } from '@lucide/svelte';
+ import { ActionIconCopyToClipboard } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { MCPResourceContent, MCPResourceInfo } from '$lib/types';
import {
- isImageMimeType,
createBase64DataUrl,
- getResourceTextContent,
+ downloadResourceContent,
getResourceBlobContent,
- downloadResourceContent
+ getResourceTextContent,
+ isImageMimeType
} from '$lib/utils';
- import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
- import { ActionIconCopyToClipboard } from '$lib/components/app';
- import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
interface Props {
resource: MCPResourceInfo | null;
class?: string;
}
- let { resource, preloadedContent, class: className }: Props = $props();
+ let { class: className, preloadedContent, resource }: Props = $props();
let content = $state<MCPResourceContent[] | null>(null);
let isLoading = $state(false);
try {
const result = await mcpStore.readResource(uri);
+
if (result) {
content = result;
} else {
function handleDownload() {
const text = getResourceTextContent(content);
+
if (!text || !resource) return;
+
downloadResourceContent(
text,
resource.mimeType || MimeTypeText.PLAIN,
<script lang="ts">
- import { Button } from '$lib/components/ui/button';
import { InputWithSuggestions } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
- import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import type { MCPResourceTemplateInfo } from '$lib/types';
import {
debounce,
- extractTemplateVariables,
expandTemplate,
+ extractTemplateVariables,
isTemplateComplete
} from '$lib/utils';
onCancel: () => void;
}
- let { template, onResolve, onCancel }: Props = $props();
+ let { onCancel, onResolve, template }: Props = $props();
const variables = $derived(extractTemplateVariables(template.uriTemplate));
<script lang="ts">
+ import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
+ import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
+ import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
- import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
- import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+ import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
- import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
- import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
- import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
}
let {
+ class: className,
+ expandToUri,
onSelect,
- onToggle,
onTemplateSelect,
- selectedUris = new Set(),
+ onToggle,
selectedTemplateUri,
- expandToUri,
- class: className
+ selectedUris = new Set()
}: Props = $props();
let expandedServers = new SvelteSet<string>();
serverName.toLowerCase().includes(query)
);
});
-
const filteredTemplates = serverRes.templates.filter((t) => {
return (
t.name?.toLowerCase().includes(query) ||
function autoExpandToResource(uri: string) {
for (const [serverName, serverRes] of resources.entries()) {
const resource = serverRes.resources.find((r) => r.uri === uri);
+
if (resource) {
expandedServers.add(serverName);
const pathParts = parseResourcePath(uri);
+
if (pathParts.length > 1) {
let currentPath = '';
+
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = `${currentPath}/${pathParts[i]}`;
const folderId = `${serverName}:${currentPath}`;
+
expandedFolders.add(folderId);
}
}
+
break;
}
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { RefreshCw, Loader2 } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
+ import { Loader2, RefreshCw } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app/forms';
+ import { Button } from '$lib/components/ui/button';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
isLoading: boolean;
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
- import { Checkbox } from '$lib/components/ui/checkbox';
- import * as Collapsible from '$lib/components/ui/collapsible';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
- import { SvelteSet } from 'svelte/reactivity';
import {
- type ResourceTreeNode,
buildResourceTree,
countTreeResources,
+ type ResourceTreeNode,
sortTreeChildren
} from './mcp-resources-browser';
- import { getDisplayName, getResourceIcon } from '$lib/utils';
+ import { Braces, ChevronDown, ChevronRight, FolderOpen, Loader2 } from '@lucide/svelte';
import { McpServerIdentity } from '$lib/components/app/mcp';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import * as Collapsible from '$lib/components/ui/collapsible';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
+ import { getDisplayName, getResourceIcon } from '$lib/utils';
+ import { SvelteSet } from 'svelte/reactivity';
interface Props {
serverName: string;
}
let {
- serverName,
- serverRes,
- isExpanded,
- selectedUris,
- selectedTemplateUri,
expandedFolders,
- onToggleServer,
- onToggleFolder,
+ isExpanded,
onSelect,
- onToggle,
onTemplateSelect,
- searchQuery = ''
+ onToggle,
+ onToggleFolder,
+ onToggleServer,
+ searchQuery = '',
+ selectedTemplateUri,
+ selectedUris,
+ serverName,
+ serverRes
}: Props = $props();
let serverDisplayName = $derived(mcpStore.getServerDisplayName(serverName));
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
serverRes.templates.map((t) => ({
- uriTemplate: t.uriTemplate,
- name: t.name,
- title: t.title,
+ annotations: t.annotations,
description: t.description,
+ icons: t.icons,
mimeType: t.mimeType,
+ name: t.name,
serverName,
- annotations: t.annotations,
- icons: t.icons
+ title: t.title,
+ uriTemplate: t.uriTemplate
}))
);
serverName: string,
searchQuery?: string
): ResourceTreeNode {
- const root: ResourceTreeNode = { name: 'root', children: new Map() };
+ const root: ResourceTreeNode = { children: new Map(), name: 'root' };
if (!searchQuery || !searchQuery.trim()) {
for (const resource of resourceList) {
const pathParts = parseResourcePath(resource.uri);
+
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
+
if (!current.children.has(part)) {
- current.children.set(part, { name: part, children: new Map() });
+ current.children.set(part, { children: new Map(), name: part });
}
+
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
+
current.children.set(resource.uri, {
+ children: new Map(),
name: fileName,
- resource: { ...resource, serverName },
- children: new Map()
+ resource: { ...resource, serverName }
});
}
if (!resourceMatchesSearch(resource, query)) continue;
const pathParts = parseResourcePath(resource.uri);
+
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
+
if (!current.children.has(part)) {
- current.children.set(part, { name: part, children: new Map(), isFiltered: true });
+ current.children.set(part, { children: new Map(), isFiltered: true, name: part });
}
+
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
- name: fileName,
- resource: { ...resource, serverName },
children: new Map(),
- isFiltered: true
+ isFiltered: true,
+ name: fileName,
+ resource: { ...resource, serverName }
});
}
if (node.resource) return true;
const toDelete: string[] = [];
+
for (const [name, child] of node.children.entries()) {
if (!cleanupEmptyFolders(child)) {
toDelete.push(name);
export function countTreeResources(node: ResourceTreeNode): number {
if (node.resource) return 1;
+
let count = 0;
for (const child of node.children.values()) {
const bIsFolder = !b.resource && b.children.size > 0;
if (aIsFolder && !bIsFolder) return -1;
+
if (!aIsFolder && bIsFolder) return 1;
return a.name.localeCompare(b.name);
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { tick } from 'svelte';
- import * as Card from '$lib/components/ui/card';
- import { Skeleton } from '$lib/components/ui/skeleton';
- import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
- import { HealthCheckStatus } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
import {
+ McpConnectionLogs,
McpServerCardActions,
McpServerCardDeleteDialog,
McpServerCardEditForm,
McpServerCardHeader,
McpServerCardToolsList,
- McpConnectionLogs,
McpServerInfo
} from '$lib/components/app/mcp';
+ import * as Card from '$lib/components/ui/card';
+ import { Skeleton } from '$lib/components/ui/skeleton';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { HealthCheckStatus } from '$lib/enums';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { HealthCheckState, MCPServerSettingsEntry } from '$lib/types';
+ import { tick } from 'svelte';
interface Props {
server: MCPServerSettingsEntry;
onDelete: () => void;
}
- let { server, enabled, onToggle, onUpdate, onDelete }: Props = $props();
+ let { enabled, onDelete, onToggle, onUpdate, server }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(mcpStore.getServerLabel(server));
function saveEditing(url: string, headers: string, useProxy: boolean, name?: string) {
onUpdate({
- url: url,
// undefined = prefill untouched, keep any existing custom name;
// empty string = field cleared, back to the automatic label
displayName: name === undefined ? server.displayName : name.trim() || undefined,
headers: headers || undefined,
+ url: url,
useProxy: useProxy
});
isEditing = false;
<script lang="ts">
- import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
+ import { Pencil, RefreshCw, Trash2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
onDelete: () => void;
}
- let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
+ let { isHealthChecking, onDelete, onEdit, onRefresh }: Props = $props();
</script>
<div class="flex shrink-0 items-center gap-1">
<script lang="ts">
import * as Card from '$lib/components/ui/card';
- import { mode } from 'mode-watcher';
import type { RecommendedMCPServer } from '$lib/types';
+ import { mode } from 'mode-watcher';
interface Props {
server: RecommendedMCPServer;
dimmed?: boolean;
}
- let { server, onClick, selected = false, dimmed = false }: Props = $props();
+ let { dimmed = false, onClick, selected = false, server }: Props = $props();
let activeIconUrl = $derived.by(() => {
const isDark = mode.current === 'dark';
if (isDark && server.iconUrlDark) return server.iconUrlDark;
+
if (!isDark && server.iconUrlLight) return server.iconUrlLight;
return server.iconUrl;
onConfirm: () => void;
}
- let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
+ let { displayName, onConfirm, onOpenChange, open = $bindable() }: Props = $props();
</script>
<AlertDialog.Root bind:open {onOpenChange}>
<script lang="ts">
- import { Button } from '$lib/components/ui/button';
import { McpServerForm } from '$lib/components/app/mcp';
+ import { Button } from '$lib/components/ui/button';
import { parseHeadersToArray } from '$lib/utils';
interface Props {
}
let {
+ onCancel,
+ onSave,
serverId,
- serverUrl,
- serverUseProxy = false,
serverLabel = '',
- onSave,
- onCancel
+ serverUrl,
+ serverUseProxy = false
}: Props = $props();
let editUrl = $derived(serverUrl);
let urlError = $derived.by(() => {
if (!editUrl.trim()) return 'URL is required';
+
try {
new URL(editUrl);
+
return null;
} catch {
return 'Invalid URL format';
<script lang="ts">
- import { Switch } from '$lib/components/ui/switch';
- import { Badge } from '$lib/components/ui/badge';
import { McpCapabilitiesBadges, McpServerIdentity } from '$lib/components/app/mcp';
- import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Switch } from '$lib/components/ui/switch';
+ import { MCP_TRANSPORT_ICONS, MCP_TRANSPORT_LABELS } from '$lib/constants';
import { MCPTransportType } from '$lib/enums';
- import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
+ import type { MCPCapabilitiesInfo, MCPServerInfo } from '$lib/types';
interface Props {
displayName: string;
}
let {
+ capabilities,
+ disabled = false,
displayName,
- faviconUrl,
enabled,
- disabled = false,
+ faviconUrl,
onToggle,
serverInfo,
- capabilities,
transportType
}: Props = $props();
</script>
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
- import * as Collapsible from '$lib/components/ui/collapsible';
import { Badge } from '$lib/components/ui/badge';
+ import * as Collapsible from '$lib/components/ui/collapsible';
interface Tool {
name: string;
<script lang="ts">
+ import { KeyValuePairs } from '$lib/components/app';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
- import { KeyValuePairs } from '$lib/components/app';
- import type { KeyValuePair } from '$lib/types';
- import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
- import { UrlProtocol } from '$lib/enums';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
MCP_SERVER_URL_PLACEHOLDER,
REDACTED_HEADERS
} from '$lib/constants';
+ import { UrlProtocol } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
+ import type { KeyValuePair } from '$lib/types';
+ import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
interface Props {
url: string;
}
let {
- url,
headers,
+ id = 'server',
name = '',
- onNameChange,
namePlaceholder = 'Name reported by the server',
- useProxy = false,
- onUrlChange,
onHeadersChange,
+ onNameChange,
+ onUrlChange,
onUseProxyChange,
+ required = false,
+ url,
urlError = null,
- id = 'server',
- wantsAuthorization = $bindable(false),
- required = false
+ useProxy = false,
+ wantsAuthorization = $bindable(false)
}: Props = $props();
let isWebSocket = $derived(
let bearerToken = $derived.by(() => {
const auth = headerPairs.find(ownedByBearerUi);
+
if (!auth) return '';
+
return auth.value.trim().slice(BEARER_PREFIX.length).trim();
});
// behavior would otherwise pick one arbitrarily, so we strip first.
function updateBearerToken(token: string) {
const filtered = headerPairs.filter((p) => !matchesAuthorizationKey(p.key));
-
const trimmed = token.trim();
if (trimmed) {
// Only drop the entry this UI owns; a non-Bearer Authorization row
// authored in the KV section must survive a toggle off untouched.
const filtered = headerPairs.filter((p) => !ownedByBearerUi(p));
+
updateHeaderPairs(filtered);
}
}
pairs={headerPairs.filter((p) => !ownedByBearerUi(p))}
onPairsChange={(pairs) => {
const auth = headerPairs.find(ownedByBearerUi);
+
updateHeaderPairs(auth ? [...pairs, auth] : pairs);
}}
keyPlaceholder="Header name"
<script lang="ts">
import { ExternalLink } from '@lucide/svelte';
- import { Badge } from '$lib/components/ui/badge';
import { McpLogo } from '$lib/components/app/mcp';
import { TruncatedText } from '$lib/components/app/misc';
- import { sanitizeExternalUrl } from '$lib/utils';
+ import { Badge } from '$lib/components/ui/badge';
import type { MCPServerInfo } from '$lib/types';
+ import { sanitizeExternalUrl } from '$lib/utils';
interface Props {
displayName?: string;
let {
displayName,
faviconUrl = null,
- serverInfo,
iconClass = 'h-5 w-5',
iconRounded = 'rounded-sm',
+ nameClass,
+ serverInfo,
showVersion = true,
- showWebsite = true,
- nameClass
+ showWebsite = true
}: Props = $props();
let safeWebsiteUrl = $derived(
class?: string;
}
- let { instructions, class: className }: Props = $props();
+ let { class: className, instructions }: Props = $props();
let isExpanded = $state(false);
</script>
onPreview?: (code: string, language: string) => void;
}
- let { code, language, disabled = false, onPreview }: Props = $props();
+ let { code, disabled = false, language, onPreview }: Props = $props();
const showPreview = $derived(language?.toLowerCase() === FileTypeText.HTML);
</script>
<script lang="ts">
+ import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
- import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
- import { SvelteSet } from 'svelte/reactivity';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
+ import { SvelteSet } from 'svelte/reactivity';
interface Props {
conversations: DatabaseConversation[];
let {
conversations,
+ isOpen = true,
messageCountMap = new Map(),
mode,
onCancel,
- onConfirm,
- isOpen = true
+ onConfirm
}: Props = $props();
let searchQuery = $state('');
let filteredConversations = $derived(
conversations.filter((conv) => {
const name = conv.name || 'Untitled conversation';
+
return name.toLowerCase().includes(searchQuery.toLowerCase());
})
);
);
const marquee = useMarqueeSelection({
- selectedIds: () => selectedIds,
+ enabled: () => isOpen,
orderedIds: () => orderedIds,
- enabled: () => isOpen
+ selectedIds: () => selectedIds
});
function toggleAll() {
const newSet = new SvelteSet(selectedIds);
+
if (allSelected) {
filteredConversations.forEach((conv) => newSet.delete(conv.id));
} else {
filteredConversations.forEach((conv) => newSet.add(conv.id));
}
+
selectedIds = newSet;
}
function handleConfirm() {
const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+
onConfirm(selected);
}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet } from 'svelte';
interface Props {
onScrollableChange?: (isScrollable: boolean) => void;
}
- let { class: className = '', children, gapSize = '3', onScrollableChange }: Props = $props();
+ let { children, class: className = '', gapSize = '3', onScrollableChange }: Props = $props();
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
+ scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * -0.67 });
}
function scrollRight(event?: MouseEvent) {
if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
+ scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * 0.67 });
}
function updateScrollButtons() {
if (!scrollContainer) return;
- const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+ const { clientWidth, scrollLeft, scrollWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
const isScrollable = scrollWidth > clientWidth;
+
onScrollableChange?.(isScrollable);
}
if (!scrollContainer) return;
const observer = new ResizeObserver(() => updateScrollButtons());
+
observer.observe(scrollContainer);
return () => observer.disconnect();
class?: string;
}
- let { keys, variant = 'default', class: className = '' }: Props = $props();
+ let { class: className = '', keys, variant = 'default' }: Props = $props();
let baseClasses =
'px-1 pointer-events-none inline-flex select-none items-center gap-0.5 font-sans text-md font-medium opacity-0 transition-opacity -my-1';
showTooltip?: boolean;
}
- let { text, class: className = '', showTooltip = true }: Props = $props();
+ let { class: className = '', showTooltip = true, text }: Props = $props();
let textElement: HTMLSpanElement | undefined = $state();
let isTruncated = $state(false);
checkTruncation();
const observer = new ResizeObserver(checkTruncation);
+
observer.observe(textElement);
return () => observer.disconnect();
<script lang="ts">
- import { Package } from '@lucide/svelte';
- import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
import ModelId from './ModelId.svelte';
+ import { Package } from '@lucide/svelte';
+ import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
- import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
class?: string;
<script lang="ts">
+ import { TruncatedText } from '$lib/components/app';
import { ModelsService } from '$lib/services/models.service';
import { config } from '$lib/stores/settings.svelte';
- import { TruncatedText } from '$lib/components/app';
interface Props {
modelId: string;
}
let {
- modelId,
+ aliases,
+ class: className = '',
hideOrgName = false,
- showRaw = undefined,
hideQuantization,
hideTags,
- aliases,
+ modelId,
+ showRaw = undefined,
tags,
- class: className = '',
...rest
}: Props = $props();
<script lang="ts">
+ import ModelLoadHighlight from './ModelLoadHighlight.svelte';
+ import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { KeyboardKey, ServerModelStatus } from '$lib/enums';
- import { MODEL_SELECTOR_ICON } from '$lib/constants';
- import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
- import { modelsStore, routerModels } from '$lib/stores/models.svelte';
- import { modelLoadFraction } from '$lib/utils';
import {
DialogModelInformation,
DropdownMenuSearchable,
ModelsSelectorList,
ModelsSelectorOption
} from '$lib/components/app';
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
- import type { ModelItem } from './utils';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { MODEL_SELECTOR_ICON } from '$lib/constants';
+ import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+ import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
+ import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+ import { modelLoadFraction } from '$lib/utils';
interface Props {
class?: string;
const ms = useModelsSelector({
currentModel: () => currentModel,
- useGlobalSelection: () => useGlobalSelection,
onModelChange: () => onModelChange,
onOpenChange: (open) => {
isOpen = open;
highlightedId = null;
- }
+ },
+ useGlobalSelection: () => useGlobalSelection
});
$effect(() => {
function moveHighlight(direction: 1 | -1) {
const len = visualOrder.length;
+
if (len === 0) {
highlightedId = null;
+
return;
}
let index = highlightedIndex;
+
if (index === -1) {
index = direction === 1 ? 0 : len - 1;
} else {
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
void ms.handleSelect(modelId);
+
return;
}
<script lang="ts">
- import { modelsStore } from '$lib/stores/models.svelte';
- import { ModelsSelectorOption } from '$lib/components/app';
import type { GroupedModelOptions, ModelItem } from './utils';
+ import { ModelsSelectorOption } from '$lib/components/app';
+ import { modelsStore } from '$lib/stores/models.svelte';
interface Props {
groups: GroupedModelOptions;
}
let {
- groups,
- currentModel,
activeId,
- sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
- orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
- onSelect,
+ currentModel,
+ groups,
onInfoClick,
- renderOption
+ onSelect,
+ orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
+ renderOption,
+ sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
</script>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import {
CircleAlert,
Heart,
RotateCw
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
- import type { ModelOption } from '$lib/types/models';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+ import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
interface Props {
}
let {
- option,
- isSelected,
- isHighlighted,
- isFav,
hideOrgName = false,
- onSelect,
- onMouseEnter,
+ isFav,
+ isHighlighted,
+ isSelected,
+ onInfoClick,
onKeyDown,
- onInfoClick
+ onMouseEnter,
+ onSelect,
+ option
}: Props = $props();
let currentRouterModels = $derived(routerModels());
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
+
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
<script lang="ts">
+ import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
- import * as Sheet from '$lib/components/ui/sheet';
- import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import {
DialogModelInformation,
ModelId,
ModelsSelectorList,
SearchInput
} from '$lib/components/app';
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
+ import * as Sheet from '$lib/components/ui/sheet';
import { ServerModelStatus } from '$lib/enums';
+ import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
let {
class: className = '',
currentModel = null,
- onModelChange,
disabled = false,
forceForegroundText = false,
+ onModelChange,
useGlobalSelection = false
}: Props = $props();
const ms = useModelsSelector({
currentModel: () => currentModel,
- useGlobalSelection: () => useGlobalSelection,
onModelChange: () => onModelChange,
onOpenChange: (open) => {
sheetOpen = open;
- }
+ },
+ useGlobalSelection: () => useGlobalSelection
});
export function open() {
-import { SvelteMap } from 'svelte/reactivity';
import type { ModelOption } from '$lib/types/models';
+import { SvelteMap } from 'svelte/reactivity';
export interface ModelItem {
option: ModelOption;
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
+
if (!term) return options;
return options.filter(
): GroupedModelOptions {
// Loaded models
const loaded: ModelItem[] = [];
+
for (let i = 0; i < filteredOptions.length; i++) {
if (isModelLoaded(filteredOptions[i].model)) {
- loaded.push({ option: filteredOptions[i], flatIndex: i });
+ loaded.push({ flatIndex: i, option: filteredOptions[i] });
}
}
// Favorites (excluding loaded)
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
const favorites: ModelItem[] = [];
+
for (let i = 0; i < filteredOptions.length; i++) {
if (
favoriteIds.has(filteredOptions[i].model) &&
!loadedModelIds.has(filteredOptions[i].model)
) {
- favorites.push({ option: filteredOptions[i], flatIndex: i });
+ favorites.push({ flatIndex: i, option: filteredOptions[i] });
}
}
// Available models grouped by org (excluding loaded and favorites)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
+
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
+
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? '';
+
if (!orgGroups.has(key)) orgGroups.set(key, []);
- orgGroups.get(key)!.push({ option, flatIndex: i });
+
+ orgGroups.get(key)!.push({ flatIndex: i, option });
}
for (const [orgName, items] of orgGroups) {
- available.push({ orgName: orgName || null, items });
+ available.push({ items, orgName: orgName || null });
}
- return { loaded, favorites, available };
+ return { available, favorites, loaded };
}
<script lang="ts">
+ import { KeyboardShortcutInfo } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { KeyboardShortcutInfo } from '$lib/components/app';
import type { Component } from 'svelte';
interface ActionItem {
}
let {
- triggerIcon,
- triggerTooltip,
- triggerClass = '',
actions,
align = 'end',
- open = $bindable(false)
+ open = $bindable(false),
+ triggerClass = '',
+ triggerIcon,
+ triggerTooltip
}: Props = $props();
</script>
<script lang="ts">
- import type { Snippet } from 'svelte';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { SearchInput } from '$lib/components/app';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import type { Snippet } from 'svelte';
interface Props {
placeholder?: string;
}
let {
- placeholder = 'Search...',
- searchValue = $bindable(''),
- onSearchChange,
- onSearchKeyDown,
+ children,
emptyMessage = 'No items found',
+ footer,
isEmpty = false,
- children,
- footer
+ onSearchChange,
+ onSearchKeyDown,
+ placeholder = 'Search...',
+ searchValue = $bindable('')
}: Props = $props();
</script>
<script lang="ts">
+ import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
- import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte';
import {
ActionIcon,
DialogConversationRename,
Logo,
- SidebarNavigationConversationList,
- SidebarNavigationActions
+ SidebarNavigationActions,
+ SidebarNavigationConversationList
} from '$lib/components/app';
import { ROUTES } from '$lib/constants';
- import { fade } from 'svelte/transition';
- import { SvelteSet } from 'svelte/reactivity';
- import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
-
+ import { TooltipSide } from '$lib/enums';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
+ import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
+ import { RouterService } from '$lib/services/router.service';
+ import { chatStore } from '$lib/stores/chat.svelte';
import {
buildConversationTree,
- conversationsStore,
- conversations
+ conversations,
+ conversationsStore
} from '$lib/stores/conversations.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
+ import { device } from '$lib/stores/device.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { RouterService } from '$lib/services/router.service';
import { isMobile } from '$lib/stores/viewport.svelte';
- import { TooltipSide } from '$lib/enums';
- import { device } from '$lib/stores/device.svelte';
import { circIn } from 'svelte/easing';
+ import { SvelteSet } from 'svelte/reactivity';
+ import { fade } from 'svelte/transition';
interface Props {
onSearchClick?: () => void;
function toggleExpandedMode() {
isExpandedMode = !isExpandedMode;
+
if (!isExpandedMode) {
hoveredTooltip = null;
}
if (!isExpandedMode) {
isSearchModeActive = false;
searchQuery = '';
+
if (isSelectionMode) exitSelectionMode();
+
cancelMobileCollapse();
}
});
const allSelectedArePinned = $derived.by(() => {
if (selectedIds.size === 0) return false;
+
const convs = conversations();
+
for (const id of selectedIds) {
const c = convs.find((conv) => conv.id === id);
+
if (c && !c.pinned) return false;
}
+
return true;
});
const pinStateIsMixed = $derived.by(() => {
if (selectedIds.size === 0) return false;
+
const convs = conversations();
+
let anyPinned = false;
let anyUnpinned = false;
+
for (const id of selectedIds) {
const c = convs.find((conv) => conv.id === id);
+
if (!c) continue;
+
if (c.pinned) anyPinned = true;
else anyUnpinned = true;
+
if (anyPinned && anyUnpinned) return true;
}
+
return false;
});
const visibleSelectionStats = $derived.by(() => {
const visibleIds = filteredConversations.map((c) => c.id);
+
let selectedVisible = 0;
+
for (const id of visibleIds) {
if (selectedIds.has(id)) selectedVisible++;
}
+
return {
- visibleCount: visibleIds.length,
- selectedVisibleCount: selectedVisible
+ selectedVisibleCount: selectedVisible,
+ visibleCount: visibleIds.length
};
});
function enterSelectionMode(id?: string) {
isSelectionMode = true;
+
if (id !== undefined) {
selectedIds.add(id);
}
async function handleBulkDelete() {
const ids = Array.from(selectedIds);
+
if (ids.length === 0) return;
+
await conversationsStore.bulkDeleteConversations(ids);
exitSelectionMode();
}
async function handleBulkPinToggle() {
const ids = Array.from(selectedIds);
+
if (ids.length === 0) return;
+
await conversationsStore.bulkToggleConversationPin(ids);
}
async function handleBulkExport() {
const ids = Array.from(selectedIds);
+
if (ids.length === 0) return;
+
await conversationsStore.bulkExportConversations(ids);
}
const marquee = useMarqueeSelection({
- selectedIds: () => selectedIds,
+ enabled: () => isSelectionMode,
orderedIds: () => renderedOrderIds,
- enabled: () => isSelectionMode
+ selectedIds: () => selectedIds
});
function handleRowMouseDown(id: string, event: MouseEvent) {
if (!isSelectionMode) return;
+
marquee.rowMouseDown(id, event);
}
function handleSelectionClick(id: string, options: { shiftKey: boolean }): void {
if (!isSelectionMode) return;
+
marquee.rowClick(id, options.shiftKey);
}
if (isMobile.current) {
scheduleMobileCollapse();
}
+
await goto(RouterService.chat(id));
}
async function handleEditConversation(id: string) {
const conversation = conversations().find((conv) => conv.id === id);
+
if (!conversation) return;
renameTargetConversationId = id;
async function handleRenameConfirm() {
const id = renameTargetConversationId;
+
if (!id) return;
const nextName = renameDraft.trim();
+
if (!nextName || nextName === renameOriginalTitle.trim()) return;
await conversationsStore.updateConversationName(id, nextName);
async function handleDeleteConversation(id: string) {
const conversation = conversations().find((conv) => conv.id === id);
+
if (!conversation) return;
const confirmed = window.confirm(
`Delete "${conversation.name}"? This action cannot be undone.`
);
+
if (!confirmed) return;
await conversationsStore.deleteConversation(id, { deleteWithForks: false });
if (pendingCollapse) {
clearTimeout(pendingCollapse);
}
+
pendingCollapse = setTimeout(() => {
isExpandedMode = false;
pendingCollapse = null;
!isExpandedMode
? 'opacity-0 h-0!'
: ''}"
- in:fade={{ duration: 150, easing: circIn, delay: 50 }}
+ in:fade={{ delay: 50, duration: 150, easing: circIn }}
out:fade={{ duration: 100 }}
>
<ActionIcon
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+ import { Search } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
- import { Search } from '@lucide/svelte';
import { ActionIcon, KeyboardShortcutInfo, SearchInput } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import {
- ICON_STRIP_TRANSITION_DURATION,
ICON_STRIP_TRANSITION_DELAY_MULTIPLIER,
+ ICON_STRIP_TRANSITION_DURATION,
ROUTES,
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { TooltipSide } from '$lib/enums';
- import { fade } from 'svelte/transition';
- import { circIn } from 'svelte/easing';
- import { onMount } from 'svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
import type { Component } from 'svelte';
+ import { onMount } from 'svelte';
+ import { circIn } from 'svelte/easing';
+ import { fade } from 'svelte/transition';
interface Props {
class: string;
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
- searchQuery = $bindable(''),
- onSearchDeactivated,
+ onNewChat,
onSearchClick,
- onNewChat
+ onSearchDeactivated,
+ searchQuery = $bindable('')
}: Props = $props();
let initialized = $state(false);
? undefined
: onSearchClick}
{@const itemTransition = {
- duration: ICON_STRIP_TRANSITION_DURATION,
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
+ duration: ICON_STRIP_TRANSITION_DURATION,
easing: circIn
}}
? undefined
: onSearchClick}
{@const itemTransition = {
- duration: ICON_STRIP_TRANSITION_DURATION,
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
+ duration: ICON_STRIP_TRANSITION_DURATION,
easing: circIn
}}
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import {
- Trash2,
- Pencil,
- MoreHorizontal,
Download,
- Loader2,
- Square,
GitBranch,
+ ListChecks,
+ Loader2,
+ MoreHorizontal,
+ Pencil,
Pin,
PinOff,
- ListChecks
+ Square,
+ Trash2
} from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
- import * as Tooltip from '$lib/components/ui/tooltip';
+ import { TruncatedText } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
+ import * as Tooltip from '$lib/components/ui/tooltip';
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { RouterService } from '$lib/services/router.service';
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { TruncatedText } from '$lib/components/app';
import { onMount } from 'svelte';
interface Props {
let {
conversation,
+ depth = 0,
+ isActive = false,
+ isSelected = false,
+ isSelectionMode = false,
onDelete,
onEdit,
- onSelect,
- onStop,
- onToggleSelect,
onEnterSelectionMode,
- onSelectionClick,
onRowMouseDown,
- isActive = false,
- isSelectionMode = false,
- isSelected = false,
- depth = 0
+ onSelect,
+ onSelectionClick,
+ onStop,
+ onToggleSelect
}: Props = $props();
let renderActionsDropdown = $state(false);
function handleMouseOver() {
if (isSelectionMode) return;
+
renderActionsDropdown = true;
}
function handleCheckboxClick(event: MouseEvent) {
event.stopPropagation();
+
if (isSelectionMode) {
onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
} else {
function handleCheckboxKeydown(event: KeyboardEvent) {
if (event.key !== ' ' && event.key !== 'Enter') return;
+
event.stopPropagation();
event.preventDefault();
+
if (isSelectionMode) {
onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
} else {
icon: Trash2,
label: 'Delete',
onclick: handleDelete,
- variant: 'destructive',
+ separator: true,
shortcut: ['shift', 'cmd', 'd'],
- separator: true
+ variant: 'destructive'
}
]}
/>
<script lang="ts">
- import { Pin } from '@lucide/svelte';
- import { buildConversationTree } from '$lib/stores/conversations.svelte';
import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
import SidebarNavigationSearchResults from './SidebarNavigationSearchResults.svelte';
import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
+ import { Pin } from '@lucide/svelte';
+ import { buildConversationTree } from '$lib/stores/conversations.svelte';
interface Props {
class: string;
}
let {
+ allSelectedArePinned,
+ allVisibleSelected,
class: className,
- filteredConversations,
currentChatId,
+ filteredConversations,
isSearchModeActive,
- searchQuery,
isSelectionMode = false,
- selectedIds = new Set<string>(),
- onSelect,
- onEdit,
+ onBulkDelete,
+ onBulkExport,
+ onBulkPinToggle,
+ onCloseSelection,
onDelete,
- onStop,
- onToggleSelect,
+ onEdit,
onEnterSelectionMode,
- onSelectionClick,
onRowMouseDown,
- visibleCount,
- allVisibleSelected,
- someVisibleSelected,
- allSelectedArePinned,
- pinStateIsMixed,
+ onSelect,
onSelectAllToggle,
- onBulkPinToggle,
- onBulkExport,
- onBulkDelete,
- onCloseSelection
+ onSelectionClick,
+ onStop,
+ onToggleSelect,
+ pinStateIsMixed,
+ searchQuery,
+ selectedIds = new Set<string>(),
+ someVisibleSelected,
+ visibleCount
}: Props = $props();
let conversationTree = $derived(buildConversationTree(filteredConversations));
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
- id: conversation.id,
- name: conversation.name,
- lastModified: conversation.lastModified,
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
+ id: conversation.id,
+ lastModified: conversation.lastModified,
+ name: conversation.name,
pinned: conversation.pinned
}}
{depth}
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
- id: conversation.id,
- name: conversation.name,
- lastModified: conversation.lastModified,
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
+ id: conversation.id,
+ lastModified: conversation.lastModified,
+ name: conversation.name,
pinned: conversation.pinned
}}
{depth}
}
let {
- value = $bindable(''),
- placeholder = 'Search conversations...',
+ class: className,
onInput,
- class: className
+ placeholder = 'Search conversations...',
+ value = $bindable('')
}: Props = $props();
</script>
<script lang="ts">
- import { buildConversationTree } from '$lib/stores/conversations.svelte';
import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
+ import { buildConversationTree } from '$lib/stores/conversations.svelte';
interface Props {
class?: string;
let {
class: className = '',
- searchQuery,
- filteredConversations,
currentChatId,
+ filteredConversations,
isSelectionMode = false,
- selectedIds = new Set<string>(),
- onSelect,
- onEdit,
onDelete,
- onStop,
- onToggleSelect,
+ onEdit,
onEnterSelectionMode,
+ onRowMouseDown,
+ onSelect,
onSelectionClick,
- onRowMouseDown
+ onStop,
+ onToggleSelect,
+ searchQuery,
+ selectedIds = new Set<string>()
}: Props = $props();
let tree = $derived(buildConversationTree(filteredConversations));
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
- id: conversation.id,
- name: conversation.name,
- lastModified: conversation.lastModified,
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
+ id: conversation.id,
+ lastModified: conversation.lastModified,
+ name: conversation.name,
pinned: conversation.pinned
}}
{depth}
}
let {
+ allVisibleSelected,
class: className = '',
+ onBulkDelete,
+ onBulkExport,
+ onBulkPinToggle,
+ onClose,
+ onSelectAllToggle,
+ pinStateIsMixed,
selectedCount,
- visibleCount,
- allVisibleSelected,
- someVisibleSelected,
someSelectedPinned,
- pinStateIsMixed,
- onSelectAllToggle,
- onBulkPinToggle,
- onBulkExport,
- onBulkDelete,
- onClose
+ someVisibleSelected,
+ visibleCount
}: Props = $props();
let showDeleteDialog = $state(false);
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { base } from '$app/paths';
- import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
+ import { AlertTriangle, CheckCircle, Key, RefreshCw, XCircle } from '@lucide/svelte';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
- import { serverStore, serverLoading } from '$lib/stores/server.svelte';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
import { AUTHORIZATION_HEADER, BEARER_PREFIX, SETTINGS_KEYS } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ROUTES } from '$lib/constants/routes';
- import { fade, fly, scale } from 'svelte/transition';
import { KeyboardKey } from '$lib/enums';
+ import { serverLoading, serverStore } from '$lib/stores/server.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { fade, fly, scale } from 'svelte/transition';
interface Props {
class?: string;
showApiKeyInput = true;
// Pre-fill with current API key if it exists
const currentConfig = config();
+
apiKeyInput = currentConfig.apiKey?.toString() || '';
}
// Test the API key by making a real request to the server
const response = await fetch(`${base}/props`, {
headers: {
- 'Content-Type': 'application/json',
- [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKeyInput.trim()}`
+ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKeyInput.trim()}`,
+ 'Content-Type': 'application/json'
}
});
</div>
{#if isAccessDeniedError && !showApiKeyInput}
- <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
+ <div in:fly={{ delay: 200, duration: 300, y: 10 }} class="mb-4">
<Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
<Key class={ICON_CLASS_DEFAULT} />
Enter API Key
{/if}
{#if showApiKeyInput}
- <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4 space-y-3 text-left">
+ <div in:fly={{ delay: 200, duration: 300, y: 10 }} class="mb-4 space-y-3 text-left">
<div class="space-y-2">
<Label for="api-key-input" class="text-sm font-medium">API Key</Label>
{/if}
</div>
{#if apiKeyError}
- <p class="text-sm text-destructive" in:fly={{ y: -10, duration: 200 }}>
+ <p class="text-sm text-destructive" in:fly={{ duration: 200, y: -10 }}>
{apiKeyError}
</p>
{/if}
{#if apiKeyState === 'success'}
- <p class="text-sm text-green-600" in:fly={{ y: -10, duration: 200 }}>
+ <p class="text-sm text-green-600" in:fly={{ duration: 200, y: -10 }}>
✓ API key validated successfully! Connecting...
</p>
{/if}
{/if}
{#if showRetry}
- <div in:fly={{ y: 10, duration: 300, delay: 200 }}>
+ <div in:fly={{ delay: 200, duration: 300, y: 10 }}>
<Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
{#if isServerLoading}
<RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin" />
{/if}
{#if showTroubleshooting}
- <div class="mt-4 text-left" in:fly={{ y: 10, duration: 300, delay: 400 }}>
+ <div class="mt-4 text-left" in:fly={{ delay: 400, duration: 300, y: 10 }}>
<details class="text-sm">
<summary class="cursor-pointer text-muted-foreground hover:text-foreground">
Troubleshooting
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { AlertTriangle, Server } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
- import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { singleModelName } from '$lib/stores/models.svelte';
+ import { serverError, serverLoading, serverProps } from '$lib/stores/server.svelte';
interface Props {
class?: string;
function getStatusColor() {
if (loading) return 'bg-yellow-500';
+
if (error) return 'bg-red-500';
+
if (serverData) return 'bg-green-500';
return 'bg-gray-500';
function getStatusText() {
if (loading) return 'Connecting...';
+
if (error) return 'Connection Error';
+
if (serverData) return 'Connected';
return 'Unknown';
<script lang="ts">
+ import { RefreshCw } from '@lucide/svelte';
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
import {
SettingsChatDesktopSidebar,
SettingsChatFields,
SettingsChatToolsTab,
SettingsFooter
} from '$lib/components/app/settings';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { Button } from '$lib/components/ui/button';
import {
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_CHAT_SECTIONS,
SETTINGS_SECTION_TITLES
} from '$lib/constants';
- import type { SettingsSection } from '$lib/types';
- import { RouterService } from '$lib/services/router.service';
- import { setMode } from 'mode-watcher';
- import { ColorMode } from '$lib/enums/ui.enums';
- import { fade } from 'svelte/transition';
- import { goto } from '$app/navigation';
- import { Button } from '$lib/components/ui/button';
- import { RefreshCw } from '@lucide/svelte';
- import { page } from '$app/state';
import { setChatSettingsConfigContext } from '$lib/contexts';
- import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
+ import { ColorMode } from '$lib/enums/ui.enums';
+ import { RouterService } from '$lib/services/router.service';
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
+ import type { SettingsSection } from '$lib/types';
+ import { setMode } from 'mode-watcher';
+ import { fade } from 'svelte/transition';
interface Props {
initialSection?: string;
getSectionHref?: (section: SettingsSection) => string;
}
- let { initialSection, getSectionHref }: Props = $props();
+ let { getSectionHref, initialSection }: Props = $props();
let activeSlug = $derived(
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
} catch (error) {
alert('Invalid JSON in custom parameters. Please check the format and try again.');
console.error(error);
+
return;
}
}
for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
const numValue = Number(processedConfig[field]);
+
if (!isNaN(numValue)) {
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
).find((entry) => entry.key === field);
const lo = entryByMinMax?.min ?? 1;
const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
+
processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
} else {
processedConfig[field] = numValue;
}
} else {
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
+
return;
}
}
}
setChatSettingsConfigContext({
+ handleConfigChange,
+ handleThemeChange,
get localConfig() {
return localConfig;
- },
- handleConfigChange,
- handleThemeChange
+ }
});
</script>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { RotateCcw, FlaskConical } from '@lucide/svelte';
+ import { FlaskConical, RotateCcw } from '@lucide/svelte';
+ import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { SettingsFieldType } from '$lib/enums/settings.enums';
- import { settingsStore } from '$lib/stores/settings.svelte';
+ import { modelsStore, propsCacheVersion, selectedModelName } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
- import { modelsStore, selectedModelName, propsCacheVersion } from '$lib/stores/models.svelte';
+ import { settingsStore } from '$lib/stores/settings.svelte';
import { normalizeFloatingPoint } from '$lib/utils/precision';
- import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import type { Component } from 'svelte';
interface Props {
>;
}
}
+
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
});
</script>
{@const serverDefault = currentModelParams[field.key]}
{@const isCustomRealTime = (() => {
if (serverDefault == null) return false;
+
if (currentValue === '') return false;
const numericInput = parseFloat(currentValue);
{@const serverDefault = currentModelParams[field.key]}
{@const isCustomRealTime = (() => {
if (serverDefault == null) return false;
+
if (currentValue === '' || currentValue === undefined) return false;
+
return currentValue !== serverDefault;
})()}
<script lang="ts">
+ import { Button, type ButtonVariant } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Component } from 'svelte';
- import { Button, type ButtonVariant } from '$lib/components/ui/button';
let {
- title,
+ buttonClass,
+ buttonText,
+ buttonVariant,
description,
IconComponent,
- buttonText,
onclick,
+ summary,
+ title,
titleClass,
- buttonVariant,
- buttonClass,
- wrapperClass,
- summary
+ wrapperClass
}: {
title: string;
description: string;
<script lang="ts">
- import { Download, Upload, Trash2 } from '@lucide/svelte';
+ import SettingsChatImportExportSection from './SettingsChatImportExportSection.svelte';
+ import { Download, Trash2, Upload } from '@lucide/svelte';
import {
- DialogConversationSelection,
DialogConfirmation,
+ DialogConversationSelection,
DialogExportSettings
} from '$lib/components/app';
- import { createMessageCountMap } from '$lib/utils';
+ import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
+ import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
+ import { conversations, conversationsStore } from '$lib/stores/conversations.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
- import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
- import { toast } from 'svelte-sonner';
+ import { createMessageCountMap } from '$lib/utils';
import { fade } from 'svelte/transition';
- import { ConversationSelectionMode, HtmlInputType, FileExtensionText } from '$lib/enums';
- import SettingsChatImportExportSection from './SettingsChatImportExportSection.svelte';
- import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
+ import { toast } from 'svelte-sonner';
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+
a.href = url;
a.download = `llama_settings_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
function handleSettingsImport() {
try {
const input = document.createElement('input');
+
input.type = HtmlInputType.FILE;
input.accept = FileExtensionText.JSON;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
+
if (!file) return;
try {
if (!data || typeof data !== 'object' || !data.config) {
toast.error('Invalid settings file: missing config');
+
return;
}
async function handleExportClick() {
try {
const allConversations = conversations();
+
if (allConversations.length === 0) {
toast.info('No conversations to export');
+
return;
}
const conversationsWithMessages = await Promise.all(
allConversations.map(async (conv: DatabaseConversation) => {
const messages = await conversationsStore.getConversationMessages(conv.id);
+
return { conv, messages };
})
);
const allData: ExportedConversation[] = await Promise.all(
selectedConversations.map(async (conv) => {
const messages = await conversationsStore.getConversationMessages(conv.id);
+
return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
})
);
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
+
if (!file) return;
try {
const selectedData = $state
.snapshot(fullImportData)
.filter((item) => selectedIds.has(item.conv.id));
-
const { imported, skipped } = await conversationsStore.importConversationsData(selectedData);
// A conversation already in the database is left untouched, so the summary
if (allConversations.length === 0) {
toast.info('No conversations to delete');
+
return;
}
IconComponent={Download}
buttonText="Export conversations"
onclick={handleExportClick}
- summary={{ show: showExportSummary, verb: 'Exported', items: exportedConversations }}
+ summary={{ items: exportedConversations, show: showExportSummary, verb: 'Exported' }}
/>
<SettingsChatImportExportSection
IconComponent={Upload}
buttonText="Import conversations"
onclick={handleImportClick}
- summary={{ show: showImportSummary, verb: 'Imported', items: importedConversations }}
+ summary={{ items: importedConversations, show: showImportSummary, verb: 'Imported' }}
/>
<SettingsChatImportExportSection
IconComponent={Download}
buttonText="Export settings"
onclick={handleSettingsExport}
- summary={{ show: showSettingsExportSummary, verb: 'Exported', items: [] }}
+ summary={{ items: [], show: showSettingsExportSummary, verb: 'Exported' }}
/>
<SettingsChatImportExportSection
IconComponent={Upload}
buttonText="Import settings"
onclick={handleSettingsImport}
- summary={{ show: showSettingsImportSummary, verb: 'Imported', items: [] }}
+ summary={{ items: [], show: showSettingsImportSummary, verb: 'Imported' }}
/>
</SettingsGroup>
</div>
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ChevronDown, ChevronRight } from '@lucide/svelte';
+ import { McpServerIdentity, TruncatedText } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
- import { TruncatedText, McpServerIdentity } from '$lib/components/app';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { permissionsStore } from '$lib/stores/permissions.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ToolSource } from '$lib/enums/tools.enums';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { permissionsStore } from '$lib/stores/permissions.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
import { SvelteSet } from 'svelte/reactivity';
let expandedGroups = new SvelteSet<string>();
<script lang="ts">
- import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Settings } from '@lucide/svelte';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
interface Props {
onSectionChange?: (section: SettingsSectionTitle) => void;
}
- let { sections, isActive, getHref, onSectionChange }: Props = $props();
+ let { getHref, isActive, onSectionChange, sections }: Props = $props();
</script>
<div class="sticky top-2 hidden w-64 flex-col self-start bg-background py-4 md:flex gap-6">
<script lang="ts">
+ import { ChevronLeft, ChevronRight, Settings } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
- import { onMount, tick } from 'svelte';
- import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
+ import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
+ import { onMount, tick } from 'svelte';
interface Props {
sections: SettingsSection[];
onSectionChange?: (section: SettingsSectionTitle) => void;
}
- let { sections, isActive, getHref, onSectionChange }: Props = $props();
+ let { getHref, isActive, onSectionChange, sections }: Props = $props();
const carousel = useScrollCarousel();
onMount(async () => {
await tick();
+
if (carousel.scrollContainer) {
const activeTab = carousel.scrollContainer.querySelector('[data-active="true"]');
+
if (activeTab instanceof HTMLElement) {
carousel.scrollToCenter(activeTab);
}
<script lang="ts">
- import { Button } from '$lib/components/ui/button';
+ import { RotateCcw } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { Button } from '$lib/components/ui/button';
import { settingsStore } from '$lib/stores/settings.svelte';
- import { RotateCcw } from '@lucide/svelte';
interface Props {
onReset?: () => void;
children: Snippet;
}
- let { title, children }: Props = $props();
+ let { children, title }: Props = $props();
</script>
<div>
<script lang="ts">
- import { X, Plus } from '@lucide/svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { Button } from '$lib/components/ui/button';
- import * as Empty from '$lib/components/ui/empty';
+ import McpLogo from '../mcp/McpLogo.svelte';
+ import { Plus, X } from '@lucide/svelte';
+ import { browser } from '$app/environment';
+ import { goto, replaceState } from '$app/navigation';
+ import { page } from '$app/state';
import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
- import { HealthCheckStatus } from '$lib/enums';
+ import { Button } from '$lib/components/ui/button';
+ import * as Empty from '$lib/components/ui/empty';
import { ROUTES } from '$lib/constants';
- import { fade } from 'svelte/transition';
+ import { HealthCheckStatus } from '$lib/enums';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { toolsStore } from '$lib/stores/tools.svelte';
import { onMount } from 'svelte';
- import McpLogo from '../mcp/McpLogo.svelte';
- import { browser } from '$app/environment';
- import { page } from '$app/state';
- import { goto, replaceState } from '$app/navigation';
+ import { fade } from 'svelte/transition';
interface Props {
class?: string;
$effect(() => {
const currentId = page.route.id;
+
return () => {
previousRouteId = currentId;
};
function handleClose() {
const prevIsMcpServers = previousRouteId === '/mcp-servers';
+
if (browser && window.history.length > 1 && !prevIsMcpServers) {
history.back();
} else {
isAddingServer = true;
const newUrl = new URL(page.url);
+
newUrl.searchParams.delete('add');
replaceState(newUrl, {});
// renders and keeps the enable toggle reachable.
function isServerPending(serverId: string, enabled: boolean): boolean {
const status = mcpStore.getHealthCheckState(serverId).status;
+
return (
status === HealthCheckStatus.CONNECTING || (status === HealthCheckStatus.IDLE && enabled)
);
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
+
await conversationsStore.toggleMcpServerForChat(server.id);
+
if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become
// available right away instead of waiting for the next chat-init.
<script lang="ts">
- import { APPLE_META_TAGS, MEDIA_QUERIES, THEME_COLORS } from '$lib/constants/pwa';
import { APP_NAME } from '$lib/constants';
+ import { APPLE_META_TAGS, MEDIA_QUERIES, THEME_COLORS } from '$lib/constants/pwa';
let { appName = APP_NAME } = $props();
</script>
<script lang="ts">
- import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
+ import * as Card from '$lib/components/ui/card';
- let { needRefresh: needRefreshProp, updateServiceWorker, forceReload } = $props();
+ let { forceReload, needRefresh: needRefreshProp, updateServiceWorker } = $props();
let needRefresh = $derived(needRefreshProp ?? false);
</script>
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
portalProps,
+ ref = $bindable(null),
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<script lang="ts">
- import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
-import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
-import Trigger from './alert-dialog-trigger.svelte';
-import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
+import Content from './alert-dialog-content.svelte';
+import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
-import Content from './alert-dialog-content.svelte';
-import Description from './alert-dialog-description.svelte';
+import Title from './alert-dialog-title.svelte';
+import Trigger from './alert-dialog-trigger.svelte';
+import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts" module>
- import { type VariantProps, tv } from 'tailwind-variants';
+ import { tv, type VariantProps } from 'tailwind-variants';
export const alertVariants = tv({
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ defaultVariants: {
+ variant: 'default'
+ },
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
}
- },
- defaultVariants: {
- variant: 'default'
}
});
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
+ children,
class: className,
+ ref = $bindable(null),
variant = 'default',
- children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
<script lang="ts" module>
- import { type VariantProps, tv } from 'tailwind-variants';
+ import { tv, type VariantProps } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
+ defaultVariants: {
+ variant: 'default'
+ },
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
+ destructive:
+ 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
+ outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
tertiary:
- 'bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25 border-transparent',
- destructive:
- 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
- outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
+ 'bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25 border-transparent'
}
- },
- defaultVariants: {
- variant: 'default'
}
});
</script>
<script lang="ts">
- import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- href,
+ children,
class: className,
+ href,
+ ref = $bindable(null),
variant = 'default',
- children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
children: Snippet;
}
- let { class: className, children, ...restProps }: Props = $props();
+ let { children, class: className, ...restProps }: Props = $props();
</script>
<div
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/components/ui/utils';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
- import { type VariantProps, tv } from 'tailwind-variants';
+ import { tv, type VariantProps } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ defaultVariants: {
+ size: 'default',
+ variant: 'default'
+ },
variants: {
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ icon: 'size-9',
+ 'icon-lg': 'size-10',
+ 'icon-sm': 'size-5 rounded-sm',
+ lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
+ sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5'
+ },
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
destructive:
'bg-destructive shadow-sm hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white!',
+ ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm',
+ link: 'text-primary underline-offset-4 hover:underline',
outline:
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
secondary:
- 'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25',
- ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm',
- link: 'text-primary underline-offset-4 hover:underline'
- },
- size: {
- default: 'h-9 px-4 py-2 has-[>svg]:px-3',
- sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
- lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
- 'icon-lg': 'size-10',
- icon: 'size-9',
- 'icon-sm': 'size-5 rounded-sm'
+ 'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
}
- },
- defaultVariants: {
- variant: 'default',
- size: 'default'
}
});
<script lang="ts">
let {
+ children,
class: className,
- variant = 'default',
- size = 'default',
- ref = $bindable(null),
+ disabled,
href = undefined,
+ ref = $bindable(null),
+ size = 'default',
type = 'button',
- disabled,
- children,
+ variant = 'default',
...restProps
}: ButtonProps = $props();
</script>
<a
bind:this={ref}
data-slot="button"
- class={cn(buttonVariants({ variant, size }), className)}
+ class={cn(buttonVariants({ size, variant }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
<button
bind:this={ref}
data-slot="button"
- class={cn(buttonVariants({ variant, size }), className)}
+ class={cn(buttonVariants({ size, variant }), className)}
{type}
{disabled}
{...restProps}
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
import { BOX_BORDER } from '$lib/constants';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import Root from './card.svelte';
+import Action from './card-action.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
-import Action from './card-action.svelte';
export {
Root,
<script lang="ts">
- import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { Checkbox as CheckboxPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
checked = $bindable(false),
- indeterminate = $bindable(false),
class: className,
+ indeterminate = $bindable(false),
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
open = $bindable(false),
+ ref = $bindable(null),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
import Root from './collapsible.svelte';
-import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
+import Trigger from './collapsible-trigger.svelte';
export {
Root,
<script lang="ts">
- import { Dialog as DialogPrimitive } from 'bits-ui';
- import XIcon from '@lucide/svelte/icons/x';
- import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
+ import XIcon from '@lucide/svelte/icons/x';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils';
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import type { Snippet } from 'svelte';
let {
- ref = $bindable(null),
+ children,
class: className,
portalProps,
- children,
+ ref = $bindable(null),
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
<script lang="ts">
- import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils';
+ import { Dialog as DialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils';
+ import { Dialog as DialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<script lang="ts">
- import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils';
+ import { Dialog as DialogPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
-import { Dialog as DialogPrimitive } from 'bits-ui';
-
-import Title from './dialog-title.svelte';
+import Close from './dialog-close.svelte';
+import Content from './dialog-content.svelte';
+import Description from './dialog-description.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
-import Content from './dialog-content.svelte';
-import Description from './dialog-description.svelte';
+import Title from './dialog-title.svelte';
import Trigger from './dialog-trigger.svelte';
-import Close from './dialog-close.svelte';
+import { Dialog as DialogPrimitive } from 'bits-ui';
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
let {
- ref = $bindable(null),
checked = $bindable(false),
- indeterminate = $bindable(false),
- class: className,
children: childrenProp,
+ class: className,
+ indeterminate = $bindable(false),
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
+ class: className,
+ portalProps,
ref = $bindable(null),
sideOffset = 4,
- portalProps,
- class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
- ref = $bindable(null),
class: className,
inset,
+ ref = $bindable(null),
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
inset,
+ ref = $bindable(null),
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
+ children,
class: className,
inset,
- children,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
- class: className,
children: childrenProp,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<script lang="ts">
- import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
+ children,
class: className,
inset,
- children,
+ ref = $bindable(null),
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
-import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
+import GroupHeading from './dropdown-menu-group-heading.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
-import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
-import GroupHeading from './dropdown-menu-group-heading.svelte';
+import Trigger from './dropdown-menu-trigger.svelte';
+import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
export const emptyMediaVariants = tv({
base: 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
+ defaultVariants: {
+ variant: 'default'
+ },
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4"
}
- },
- defaultVariants: {
- variant: 'default'
}
});
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
variant = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
import Root from './empty.svelte';
+import Content from './empty-content.svelte';
+import Description from './empty-description.svelte';
import Header from './empty-header.svelte';
import Media from './empty-media.svelte';
import Title from './empty-title.svelte';
-import Description from './empty-description.svelte';
-import Content from './empty-content.svelte';
export {
Root,
<script lang="ts">
- import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
- import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
import HoverCardPortal from './hover-card-portal.svelte';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
- ref = $bindable(null),
- class: className,
align = 'center',
- sideOffset = 4,
+ class: className,
portalProps,
+ ref = $bindable(null),
+ sideOffset = 4,
...restProps
}: HoverCardPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof HoverCardPortal>>;
import Root from './hover-card.svelte';
import Content from './hover-card-content.svelte';
-import Trigger from './hover-card-trigger.svelte';
import Portal from './hover-card-portal.svelte';
+import Trigger from './hover-card-trigger.svelte';
export {
Root,
<script lang="ts">
- import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
>;
let {
+ class: className,
+ files = $bindable(),
ref = $bindable(null),
- value = $bindable(),
type,
- files = $bindable(),
- class: className,
+ value = $bindable(),
...restProps
}: Props = $props();
</script>
<script lang="ts">
- import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Label as LabelPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
import Root from './popover.svelte';
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
-import Trigger from './popover-trigger.svelte';
import Portal from './popover-portal.svelte';
+import Trigger from './popover-trigger.svelte';
export {
Root,
<script lang="ts">
- import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { Popover as PopoverPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
- ref = $bindable(null),
- class: className,
- sideOffset = 4,
- side,
align = 'center',
- collisionPadding = 8,
avoidCollisions = true,
+ class: className,
+ collisionPadding = 8,
portalProps,
+ ref = $bindable(null),
+ side,
+ sideOffset = 4,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<script lang="ts">
- import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<script lang="ts">
- import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
value = $bindable(''),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
-import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
+import Scrollbar from './scroll-area-scrollbar.svelte';
export {
Root,
<script lang="ts">
- import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/components/ui/utils';
+ import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
+ children,
class: className,
orientation = 'vertical',
- children,
+ ref = $bindable(null),
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<script lang="ts">
- import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { Scrollbar } from './index.js';
import { cn, type WithoutChild } from '$lib/components/ui/utils';
+ import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
+ children,
class: className,
orientation = 'vertical',
+ ref = $bindable(null),
scrollbarXClasses = '',
scrollbarYClasses = '',
- children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
-import { Select as SelectPrimitive } from 'bits-ui';
-
+import Content from './select-content.svelte';
import Group from './select-group.svelte';
-import Label from './select-label.svelte';
+import GroupHeading from './select-group-heading.svelte';
import Item from './select-item.svelte';
-import Content from './select-content.svelte';
-import Trigger from './select-trigger.svelte';
-import Separator from './select-separator.svelte';
+import Label from './select-label.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
-import GroupHeading from './select-group-heading.svelte';
+import Separator from './select-separator.svelte';
+import Trigger from './select-trigger.svelte';
+import { Select as SelectPrimitive } from 'bits-ui';
const Root = SelectPrimitive.Root;
<script lang="ts">
- import { onDestroy, onMount } from 'svelte';
- import { Select as SelectPrimitive } from 'bits-ui';
- import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
+ import SelectScrollUpButton from './select-scroll-up-button.svelte';
import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import { onDestroy, onMount } from 'svelte';
let {
- ref = $bindable(null),
+ children,
class: className,
- sideOffset = 4,
portalProps,
- children,
+ ref = $bindable(null),
+ sideOffset = 4,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
onMount(() => {
const listenerOptions: AddEventListenerOptions = { passive: false };
-
const blockOutsideWheel = (event: WheelEvent) => {
if (!ref) {
return;
event.stopPropagation();
}
};
-
const blockOutsideTouchMove = (event: TouchEvent) => {
if (!ref) {
return;
const stopWheelPropagation = (event: WheelEvent) => {
event.stopPropagation();
};
-
const stopTouchPropagation = (event: TouchEvent) => {
event.stopPropagation();
};
<script lang="ts">
- import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
- import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
+ children: childrenProp,
class: className,
- value,
label,
- children: childrenProp,
+ ref = $bindable(null),
+ value,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
)}
{...restProps}
>
- {#snippet children({ selected, highlighted })}
+ {#snippet children({ highlighted, selected })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
- {@render childrenProp({ selected, highlighted })}
+ {@render childrenProp({ highlighted, selected })}
{:else}
{label || value}
{/if}
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
- import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<script lang="ts">
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
- import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<script lang="ts">
- import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/components/ui/utils.js';
+ import type { Separator as SeparatorPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<script lang="ts">
- import { Select as SelectPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+ import { Select as SelectPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
size = 'default',
variant = 'default',
...restProps
<script lang="ts">
- import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Separator as SeparatorPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
-import { Dialog as SheetPrimitive } from 'bits-ui';
-import Trigger from './sheet-trigger.svelte';
import Close from './sheet-close.svelte';
-import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
-import Header from './sheet-header.svelte';
+import Description from './sheet-description.svelte';
import Footer from './sheet-footer.svelte';
+import Header from './sheet-header.svelte';
+import Overlay from './sheet-overlay.svelte';
import Title from './sheet-title.svelte';
-import Description from './sheet-description.svelte';
+import Trigger from './sheet-trigger.svelte';
+import { Dialog as SheetPrimitive } from 'bits-ui';
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: `border-border/30 dark:border-border/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards fixed z-50 flex flex-col gap-4 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 ${PANEL_CLASSES}`,
+ defaultVariants: {
+ side: 'right'
+ },
variants: {
side: {
- top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
- 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
+ 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
+ top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b'
}
- },
- defaultVariants: {
- side: 'right'
}
});
</script>
<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
- import XIcon from '@lucide/svelte/icons/x';
- import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
+ import XIcon from '@lucide/svelte/icons/x';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
import { PANEL_CLASSES } from '$lib/constants';
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+ import type { Snippet } from 'svelte';
let {
- ref = $bindable(null),
+ children,
class: className,
- side = 'right',
portalProps,
- children,
+ ref = $bindable(null),
+ side = 'right',
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Dialog as SheetPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Dialog as SheetPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Dialog as SheetPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<script lang="ts">
- import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { Switch as SwitchPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
- class: className,
checked = $bindable(false),
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
import type { HTMLTdAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
import type { HTMLThAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
import type { HTMLAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<script lang="ts">
- import type { HTMLTableAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLTableAttributes } from 'svelte/elements';
let {
- ref = $bindable(null),
- class: className,
children,
+ class: className,
+ ref = $bindable(null),
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
import type { HTMLTextareaAttributes } from 'svelte/elements';
let {
+ class: className,
ref = $bindable(null),
value = $bindable(),
- class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
-import { Tooltip as TooltipPrimitive } from 'bits-ui';
-import Trigger from './tooltip-trigger.svelte';
import Content from './tooltip-content.svelte';
+import Trigger from './tooltip-trigger.svelte';
+import { Tooltip as TooltipPrimitive } from 'bits-ui';
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
<script lang="ts">
- import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/components/ui/utils.js';
+ import { Tooltip as TooltipPrimitive } from 'bits-ui';
let {
- ref = $bindable(null),
- class: className,
- sideOffset = 4,
- side = 'top',
- children,
arrowClasses,
+ children,
+ class: className,
noPortal = false,
+ ref = $bindable(null),
+ side = 'top',
+ sideOffset = 4,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
-import { clsx, type ClassValue } from 'clsx';
+import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
} as const;
export const REASONING_TAGS = {
- START: '<think>',
- END: '</think>'
+ END: '</think>',
+ START: '<think>'
} as const;
/**
* New messages use structured fields (reasoningContent, toolCalls, toolCallId).
*/
export const LEGACY_AGENTIC_TAGS = {
- TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
- TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
- TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
- TOOL_ARGS_START: '<<<TOOL_ARGS_START>>>',
+ TAG_SUFFIX: '>>>',
TOOL_ARGS_END: '<<<TOOL_ARGS_END>>>',
- TAG_SUFFIX: '>>>'
+ TOOL_ARGS_START: '<<<TOOL_ARGS_START>>>',
+ TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
+ TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
+ TOOL_NAME_PREFIX: '<<<TOOL_NAME:'
} as const;
/**
* New messages use the dedicated reasoningContent field.
*/
export const LEGACY_REASONING_TAGS = {
- START: '<<<reasoning_content_start>>>',
- END: '<<<reasoning_content_end>>>'
+ END: '<<<reasoning_content_end>>>',
+ START: '<<<reasoning_content_start>>>'
} as const;
/**
* @deprecated Legacy regex patterns - only used for migration of old stored messages.
*/
export const LEGACY_AGENTIC_REGEX = {
+ AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
+ AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
COMPLETED_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
+ HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/,
REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
REASONING_EXTRACT: /<<<reasoning_content_start>>>([\s\S]*?)<<<reasoning_content_end>>>/,
- REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
- AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
- AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
- HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/
+ REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/
} as const;
export const API_MODELS = {
LIST: '/v1/models',
LOAD: '/models/load',
- UNLOAD: '/models/unload',
- SSE: '/models/sse'
+ SSE: '/models/sse',
+ UNLOAD: '/models/unload'
};
// chat completion routes, the control route drives realtime inference (e.g. end reasoning)
};
export const API_TOOLS = {
- LIST: '/tools',
- EXECUTE: '/tools'
+ EXECUTE: '/tools',
+ LIST: '/tools'
};
// resumable stream routes, the conv::model identity travels as the conv_id query param
-import type { Component } from 'svelte';
-import { MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
+import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import {
AttachmentAction,
AttachmentItemVisibleWhen,
AttachmentMenuItemId
} from '$lib/enums';
+import type { Component } from 'svelte';
export interface AttachmentMenuItem {
/** Unique identifier for the item */
*/
export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
{
- id: AttachmentMenuItemId.IMAGES,
- label: 'Images',
- icon: FILE_TYPE_ICONS.image,
+ action: AttachmentAction.FILE_UPLOAD,
class: 'images-button',
- enabledWhen: AttachmentItemEnabledWhen.HAS_VISION_MODALITY,
disabledTooltip: 'Image processing requires a vision model',
- action: AttachmentAction.FILE_UPLOAD
+ enabledWhen: AttachmentItemEnabledWhen.HAS_VISION_MODALITY,
+ icon: FILE_TYPE_ICONS.image,
+ id: AttachmentMenuItemId.IMAGES,
+ label: 'Images'
},
{
- id: AttachmentMenuItemId.AUDIO,
- label: 'Audio Files',
- icon: FILE_TYPE_ICONS.audio,
+ action: AttachmentAction.FILE_UPLOAD,
class: 'audio-button',
- enabledWhen: AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY,
disabledTooltip: 'Audio files processing requires an audio model',
- action: AttachmentAction.FILE_UPLOAD
+ enabledWhen: AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY,
+ icon: FILE_TYPE_ICONS.audio,
+ id: AttachmentMenuItemId.AUDIO,
+ label: 'Audio Files'
},
{
- id: AttachmentMenuItemId.VIDEO,
- label: 'Video Files',
- icon: FILE_TYPE_ICONS.video,
+ action: AttachmentAction.FILE_UPLOAD,
class: 'video-button',
- enabledWhen: AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY,
disabledTooltip: 'Video files processing requires a video model',
- action: AttachmentAction.FILE_UPLOAD
+ enabledWhen: AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY,
+ icon: FILE_TYPE_ICONS.video,
+ id: AttachmentMenuItemId.VIDEO,
+ label: 'Video Files'
},
{
- id: AttachmentMenuItemId.TEXT,
- label: 'Text Files',
- icon: FILE_TYPE_ICONS.text,
+ action: AttachmentAction.FILE_UPLOAD,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- action: AttachmentAction.FILE_UPLOAD
+ icon: FILE_TYPE_ICONS.text,
+ id: AttachmentMenuItemId.TEXT,
+ label: 'Text Files'
},
{
- id: AttachmentMenuItemId.PDF,
- label: 'PDF Files',
- icon: FILE_TYPE_ICONS.pdf,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
+ action: AttachmentAction.FILE_UPLOAD,
disabledTooltip: 'PDFs will be converted to text. Image-based PDFs may not work properly.',
+ enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
- action: AttachmentAction.FILE_UPLOAD
+ icon: FILE_TYPE_ICONS.pdf,
+ id: AttachmentMenuItemId.PDF,
+ label: 'PDF Files'
}
];
export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
{
- id: AttachmentMenuItemId.SYSTEM_MESSAGE,
- label: 'System Message',
- icon: MessageSquare,
+ action: AttachmentAction.SYSTEM_PROMPT_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
- action: AttachmentAction.SYSTEM_PROMPT_CLICK
+ icon: MessageSquare,
+ id: AttachmentMenuItemId.SYSTEM_MESSAGE,
+ label: 'System Message'
},
{
+ action: AttachmentAction.MCP_PROMPT_CLICK,
+ enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
+ icon: Zap,
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
- icon: Zap,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- action: AttachmentAction.MCP_PROMPT_CLICK,
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
{
+ action: AttachmentAction.MCP_RESOURCES_CLICK,
+ enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
+ icon: FolderOpen,
id: AttachmentMenuItemId.MCP_RESOURCES,
label: 'MCP Resources',
- icon: FolderOpen,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- action: AttachmentAction.MCP_RESOURCES_CLICK,
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT
}
];
import type { BinaryDetectionOptions } from '$lib/types';
export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
+ maxAbsoluteNullBytes: 2,
prefixLength: 1024 * 10, // Check the first 10KB of the string
- suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars
- maxAbsoluteNullBytes: 2
+ suspiciousCharThresholdRatio: 0.15 // Allow up to 15% suspicious chars
};
// (see ChatMessageToolCallBlockGetDatetime and
// ChatMessageToolCallBlockSearchResults for prior art).
-import type { Component } from 'svelte';
import {
Braces,
Clock,
Terminal
} from '@lucide/svelte';
import { BuiltInTool, ToolSource } from '$lib/enums';
+import type { Component } from 'svelte';
export interface BuiltinToolUiEntry {
icon: Component;
}
export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>> = {
- [BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN },
[BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN },
- [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN },
+ [BuiltInTool.EXEC_SHELL_COMMAND]: {
+ icon: Terminal,
+ label: 'Run command',
+ source: ToolSource.BUILTIN
+ },
[BuiltInTool.FILE_GLOB_SEARCH]: {
icon: FileSearch,
label: 'Search files',
source: ToolSource.BUILTIN
},
+ [BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
+ [BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
[BuiltInTool.GREP_SEARCH]: {
icon: SearchCode,
label: 'Search in files',
source: ToolSource.BUILTIN
},
- [BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
- [BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
- [BuiltInTool.EXEC_SHELL_COMMAND]: {
- icon: Terminal,
- label: 'Run command',
- source: ToolSource.BUILTIN
- },
+ [BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN },
[BuiltInTool.RUN_JAVASCRIPT]: {
icon: Braces,
label: 'Run JavaScript',
source: ToolSource.FRONTEND
- }
+ },
+ [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN }
} as const;
export function getBuiltinToolUi(toolName: string | undefined): BuiltinToolUiEntry | null {
if (!toolName) return null;
+
return (BUILTIN_TOOL_UI as Record<string, BuiltinToolUiEntry>)[toolName] ?? null;
}
export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
return [
{
- name: 'prompt',
- description: 'Insert an MCP prompt',
action: ChatFormCommandAction.PROMPT,
- disabled: !options.hasPrompts()
+ description: 'Insert an MCP prompt',
+ disabled: !options.hasPrompts(),
+ name: 'prompt'
},
{
- name: 'cwd',
+ action: ChatFormCommandAction.CWD,
description: SET_WORKING_DIRECTORY_LABEL,
+ disabled: !options.hasCwdTools(),
keywords: ['current working directory'],
- action: ChatFormCommandAction.CWD,
- disabled: !options.hasCwdTools()
+ name: 'cwd'
},
{
- name: 'model',
- description: 'Select model',
action: ChatFormCommandAction.MODEL,
- disabled: !options.showModelSelector
+ description: 'Select model',
+ disabled: !options.showModelSelector,
+ name: 'model'
}
];
}
export const ERROR_MESSAGES = {
+ HTTP: {
+ ACCESS_DENIED: 'Access denied',
+ GENERIC: 'Request failed',
+ INTERNAL_ERROR: 'Server error - check server logs',
+ NOT_FOUND: 'Not found',
+ TEMPORARILY_UNAVAILABLE: 'Server temporarily unavailable'
+ },
NETWORK: {
GENERIC: 'Failed to connect to server',
NXDOMAIN: 'Server not found - check server address',
REFUSED: 'Connection refused - server may be offline',
TIMEOUT: 'Request timed out',
UNREACHABLE: 'Server is not running or unreachable'
- },
- HTTP: {
- GENERIC: 'Request failed',
- ACCESS_DENIED: 'Access denied',
- INTERNAL_ERROR: 'Server error - check server logs',
- NOT_FOUND: 'Not found',
- TEMPORARILY_UNAVAILABLE: 'Server temporarily unavailable'
}
};
*/
import {
+ Eye as VisionIcon,
File as FileIcon,
FileText as FileTextIcon,
Image as ImageIcon,
- Eye as VisionIcon,
Mic as AudioIcon,
Video as VideoIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelModality } from '$lib/enums';
export const FILE_TYPE_ICONS = {
- [FileTypeCategory.IMAGE]: ImageIcon,
[FileTypeCategory.AUDIO]: AudioIcon,
- [FileTypeCategory.VIDEO]: VideoIcon,
+ [FileTypeCategory.IMAGE]: ImageIcon,
+ [FileTypeCategory.PDF]: FileIcon,
[FileTypeCategory.TEXT]: FileTextIcon,
- [FileTypeCategory.PDF]: FileIcon
+ [FileTypeCategory.VIDEO]: VideoIcon
} as const;
export const DEFAULT_FILE_ICON = FileIcon;
export const MODALITY_ICONS = {
- [ModelModality.VISION]: VisionIcon,
[ModelModality.AUDIO]: AudioIcon,
- [ModelModality.VIDEO]: VideoIcon
+ [ModelModality.VIDEO]: VideoIcon,
+ [ModelModality.VISION]: VisionIcon
} as const;
export const MODALITY_LABELS = {
- [ModelModality.VISION]: 'Vision',
[ModelModality.AUDIO]: 'Audio',
- [ModelModality.VIDEO]: 'Video'
+ [ModelModality.VIDEO]: 'Video',
+ [ModelModality.VISION]: 'Vision'
} as const;
// Shared SVG icon strings for copy and preview buttons
* Used for generating attachment filenames from MIME types.
*/
export const IMAGE_MIME_TO_EXTENSION: Record<string, string> = {
+ [MimeTypeImage.GIF]: 'gif',
[MimeTypeImage.JPEG]: 'jpg',
[MimeTypeImage.JPG]: 'jpg',
[MimeTypeImage.PNG]: 'png',
- [MimeTypeImage.GIF]: 'gif',
[MimeTypeImage.WEBP]: 'webp'
} as const;
-import { Zap, Globe, Radio } from '@lucide/svelte';
+import { Globe, Radio, Zap } from '@lucide/svelte';
import { MCPTransportType } from '$lib/enums';
+import { MimeTypeImage } from '$lib/enums/files.enums';
import type { ClientCapabilities, Implementation } from '$lib/types';
import type { Component } from 'svelte';
-import { MimeTypeImage } from '$lib/enums/files.enums';
export const DEFAULT_CLIENT_VERSION = '1.0.0';
export const MCP_CLIENT_NAME = 'llama-ui-mcp';
export const MCP_PROTOCOL_VERSION = '2025-06-18';
export const DEFAULT_MCP_CONFIG = {
- protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: true } } as ClientCapabilities,
clientInfo: { name: MCP_CLIENT_NAME, version: DEFAULT_CLIENT_VERSION } as Implementation,
- requestTimeoutSeconds: 300, // 5 minutes for long-running tools
- connectionTimeoutMs: 10_000 // 10 seconds for connection establishment
+ connectionTimeoutMs: 10_000, // 10 seconds for connection establishment
+ protocolVersion: MCP_PROTOCOL_VERSION,
+ requestTimeoutSeconds: 300 // 5 minutes for long-running tools
} as const;
export const MCP_SERVER_ID_PREFIX = 'LlamaUI-MCP-Server';
/** Human-readable labels for MCP transport types */
export const MCP_TRANSPORT_LABELS: Record<MCPTransportType, string> = {
- [MCPTransportType.WEBSOCKET]: 'WebSocket',
+ [MCPTransportType.SSE]: 'SSE',
[MCPTransportType.STREAMABLE_HTTP]: 'HTTP',
- [MCPTransportType.SSE]: 'SSE'
+ [MCPTransportType.WEBSOCKET]: 'WebSocket'
};
/** Icon components for MCP transport types */
export const MCP_TRANSPORT_ICONS: Record<MCPTransportType, Component> = {
- [MCPTransportType.WEBSOCKET]: Zap,
+ [MCPTransportType.SSE]: Radio,
[MCPTransportType.STREAMABLE_HTTP]: Globe,
- [MCPTransportType.SSE]: Radio
+ [MCPTransportType.WEBSOCKET]: Zap
};
/** Standard SSE endpoint path indicators */
* valid for both.
*/
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
- xmlns: 'http://www.w3.org/2000/svg',
- viewBox: '0 0 24 24',
+ 'aria-hidden': 'true',
fill: 'none',
stroke: 'currentColor',
- 'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
- 'aria-hidden': 'true'
+ 'stroke-width': '2',
+ viewBox: '0 0 24 24',
+ xmlns: 'http://www.w3.org/2000/svg'
};
/**
* Labels shown while a model loads, keyed by the stage reported on /models/sse.
*/
export const MODEL_LOAD_STAGE_LABELS: Record<ApiModelLoadStage, string> = {
- text_model: 'Loading weights',
+ mmproj_model: 'Loading projector',
spec_model: 'Loading draft',
- mmproj_model: 'Loading projector'
+ text_model: 'Loading weights'
};
/**
import { APP_NAME } from './app';
export const MEDIA_QUERIES = {
+ DISPLAY_MODE_STANDALONE: '(display-mode: standalone)',
PREFERS_DARK: '(prefers-color-scheme: dark)',
- PREFERS_LIGHT: '(prefers-color-scheme: light)',
- DISPLAY_MODE_STANDALONE: '(display-mode: standalone)'
+ PREFERS_LIGHT: '(prefers-color-scheme: light)'
} as const;
export const THEME_COLORS = {
- LIGHT: '#ffffff',
- DARK: '#0d0d0d',
ACCENT_BLUE: '#2563eb',
ACCENT_BLUE_HOVER: '#1d4ed8',
- BACKGROUND_LIGHT: 'white',
BACKGROUND_DARK: '#111111',
+ BACKGROUND_LIGHT: 'white',
+ DARK: '#0d0d0d',
+ LIGHT: '#ffffff',
TITLE_UPDATE_ALERT: {
- BORDER_LIGHT: 'zinc-200',
- BORDER_DARK: 'zinc-700',
- BG_LIGHT: 'white',
BG_DARK: 'zinc-800',
- TEXT_LIGHT: 'zinc-500',
- TEXT_DARK: 'zinc-400'
+ BG_LIGHT: 'white',
+ BORDER_DARK: 'zinc-700',
+ BORDER_LIGHT: 'zinc-200',
+ TEXT_DARK: 'zinc-400',
+ TEXT_LIGHT: 'zinc-500'
}
} as const;
export const FAVICON_PATHS = {
- ICO_LIGHT: 'favicon.ico',
ICO_DARK: 'favicon-dark.ico',
- SVG_LIGHT: 'favicon.svg',
- SVG_DARK: 'favicon-dark.svg'
+ ICO_LIGHT: 'favicon.ico',
+ SVG_DARK: 'favicon-dark.svg',
+ SVG_LIGHT: 'favicon.svg'
} as const;
// Substituted for `currentColor` in src/lib/assets/logo.svg when generating
// the light/dark static sources consumed by the PWA asset generator.
export const FAVICON_COLORS = {
- LIGHT: '#111111',
- DARK: '#fafafa'
+ DARK: '#fafafa',
+ LIGHT: '#111111'
} as const;
export const FAVICON_SELECTORS = {
} as const;
export const PWA_MANIFEST = {
- name: APP_NAME,
- short_name: APP_NAME,
+ background_color: THEME_COLORS.BACKGROUND_LIGHT,
description: 'Local AI chat interface powered by llama.cpp',
- start_url: './',
display: 'standalone' as const,
- background_color: THEME_COLORS.BACKGROUND_LIGHT,
- theme_color: THEME_COLORS.BACKGROUND_LIGHT,
icons: [
- { src: 'pwa-64x64.png', sizes: '64x64', type: 'image/png' },
- { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
- { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any' as const },
+ { sizes: '64x64', src: 'pwa-64x64.png', type: 'image/png' },
+ { sizes: '192x192', src: 'pwa-192x192.png', type: 'image/png' },
+ { purpose: 'any' as const, sizes: '512x512', src: 'pwa-512x512.png', type: 'image/png' },
{
- src: 'maskable-icon-512x512.png',
+ purpose: 'maskable' as const,
sizes: '512x512',
- type: 'image/png',
- purpose: 'maskable' as const
+ src: 'maskable-icon-512x512.png',
+ type: 'image/png'
}
- ]
+ ],
+ name: APP_NAME,
+ short_name: APP_NAME,
+ start_url: './',
+ theme_color: THEME_COLORS.BACKGROUND_LIGHT
};
export const PWA_ICON_PATHS = {
+ MASKABLE_512: '/maskable-icon-512x512.png',
PWA_64: '/pwa-64x64.png',
PWA_192: '/pwa-192x192.png',
- PWA_512: '/pwa-512x512.png',
- MASKABLE_512: '/maskable-icon-512x512.png'
+ PWA_512: '/pwa-512x512.png'
} as const;
/** Apple device dimensions (logical points) and DPR, from Apple HIG. */
export const APPLE_DEVICES = {
+ '640x1136': { dpr: 2, height: 568, width: 320 }, // iPhone 6/7/8 Plus
+ '744x1133': { dpr: 2, height: 573, width: 376 }, // iPad mini 8.3"
+ '750x1334': { dpr: 2, height: 667, width: 375 }, // iPhone 6/7/8, 14
+ '1032x1376': { dpr: 2, height: 1376, width: 1032 }, // iPad Air 13"
// iPhones (DPR 3)
- '1170x2532': { width: 390, height: 844, dpr: 3 }, // iPhone 13, 15
- '1179x2556': { width: 393, height: 852, dpr: 3 }, // iPhone 14, 15 Pro, 16
- '1206x2622': { width: 402, height: 874, dpr: 3 }, // iPhone 16 Plus, 16e
- '1284x2778': { width: 428, height: 926, dpr: 3 }, // iPhone 15 Plus
- '1290x2796': { width: 430, height: 932, dpr: 3 }, // iPhone 15 Pro Max, 16 Pro
- '1320x2868': { width: 440, height: 956, dpr: 3 }, // iPhone 16 Pro Max
- '750x1334': { width: 375, height: 667, dpr: 2 }, // iPhone 6/7/8, 14
- '640x1136': { width: 320, height: 568, dpr: 2 }, // iPhone 6/7/8 Plus
+ '1170x2532': { dpr: 3, height: 844, width: 390 }, // iPhone 13, 15
+ '1179x2556': { dpr: 3, height: 852, width: 393 }, // iPhone 14, 15 Pro, 16
+ '1206x2622': { dpr: 3, height: 874, width: 402 }, // iPhone 16 Plus, 16e
+ '1284x2778': { dpr: 3, height: 926, width: 428 }, // iPhone 15 Plus
+ '1290x2796': { dpr: 3, height: 932, width: 430 }, // iPhone 15 Pro Max, 16 Pro
+ '1320x2868': { dpr: 3, height: 956, width: 440 }, // iPhone 16 Pro Max
+ '1640x2360': { dpr: 2, height: 1180, width: 820 }, // iPad Air 10.9"
// iPads (DPR 2)
- '1668x2388': { width: 834, height: 1194, dpr: 2 }, // iPad Air 11", iPad 11"
- '2048x2732': { width: 1024, height: 1366, dpr: 2 }, // iPad Pro 12.9"
- '1640x2360': { width: 820, height: 1180, dpr: 2 }, // iPad Air 10.9"
- '1032x1376': { width: 1032, height: 1376, dpr: 2 }, // iPad Air 13"
- '744x1133': { width: 376, height: 573, dpr: 2 } // iPad mini 8.3"
+ '1668x2388': { dpr: 2, height: 1194, width: 834 }, // iPad Air 11", iPad 11"
+ '2048x2732': { dpr: 2, height: 1366, width: 1024 } // iPad Pro 12.9"
} as const;
export type AppleDeviceKey = keyof typeof APPLE_DEVICES;
'/workbox-<hash>.js'
] as const;
export const BUILD_CONFIG = {
- OUTPUT_DIR: './dist',
GUIDE_COMMENT: `
<!--
This is a static build of the frontend.
Do not edit this file directly.
To make changes, refer to the "Web UI" section in the README.
-->
-`.trim()
+`.trim(),
+ OUTPUT_DIR: './dist'
} as const;
export const REGEX_PATTERNS = {
- SPLASH_FILE: /^apple-splash-(portrait|landscape)-(dark-)?(\d+)x(\d+)\.png$/,
- HEAD_CLOSE: /\t*<\/head>/
+ HEAD_CLOSE: /\t*<\/head>/,
+ SPLASH_FILE: /^apple-splash-(portrait|landscape)-(dark-)?(\d+)x(\d+)\.png$/
} as const;
// Device names used by @vite-pwa/assets-generator for splash screen generation.
// post-processed into the static favicon.svg so the in-app logo (which reads
// src/lib/assets/logo.svg directly) is unaffected.
export const PWA_ASSET_GENERATOR = {
- LINK_PRESET: '2023',
- FAVICON_PADDING: 0.04,
- SPLASH_PADDING: 0.75,
- FIT_MODE: 'contain',
ADD_MEDIA_SCREEN: true,
BASE_PATH: './',
- XHTML: false,
+ DARK_PREFIX: 'dark-',
+ FAVICON_PADDING: 0.04,
+ FIT_MODE: 'contain',
+ LINK_PRESET: '2023',
PNG_COMPRESSION_LEVEL: 9,
PNG_QUALITY: 60,
- DARK_PREFIX: 'dark-'
+ SPLASH_PADDING: 0.75,
+ XHTML: false
} as const;
export const CACHE_SETTINGS = {
- IMMUTABLE_MAX_AGE_SECONDS: 31536000,
API_CACHE_MAX_AGE_SECONDS: 60 * 60 * 24,
API_CACHE_MAX_ENTRIES: 50,
+ IMMUTABLE_MAX_AGE_SECONDS: 31536000,
MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024
} as const;
// Runtime caching configuration for Workbox
export const RUNTIME_CACHING = {
- HANDLER: 'NetworkFirst',
- CACHE_NAME: 'api-cache'
+ CACHE_NAME: 'api-cache',
+ HANDLER: 'NetworkFirst'
} as const;
// Workbox runtime caching patterns
export const API_CACHING_PATTERNS = {
- V1_API: /^\/v1\/.*/,
- STATIC_API: /^\/(health|props|models|tools|slots|cors-proxy).*/
+ STATIC_API: /^\/(health|props|models|tools|slots|cors-proxy).*/,
+ V1_API: /^\/v1\/.*/
} as const;
// SvelteKit PWA plugin options
export const PWA_KIT_OPTIONS = {} as const;
export const APPLE_META_TAGS = {
- MOBILE_WEB_APP_CAPABLE: { name: 'apple-mobile-web-app-capable', content: 'yes' },
- STATUS_BAR_STYLE: { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
- MOBILE_WEB_APP_TITLE: { name: 'apple-mobile-web-app-title' }
+ MOBILE_WEB_APP_CAPABLE: { content: 'yes', name: 'apple-mobile-web-app-capable' },
+ MOBILE_WEB_APP_TITLE: { name: 'apple-mobile-web-app-title' },
+ STATUS_BAR_STYLE: { content: 'black-translucent', name: 'apple-mobile-web-app-status-bar-style' }
} as const;
// Splash screen HTML link tag prefix used by generateSplashScreenLinks
export const SPLASH_LINK = {
- HTML: '<link rel="apple-touch-startup-image"',
- DARK_MEDIA_SUFFIX: ' and (prefers-color-scheme: dark)'
+ DARK_MEDIA_SUFFIX: ' and (prefers-color-scheme: dark)',
+ HTML: '<link rel="apple-touch-startup-image"'
} as const;
// SvelteKit PWA plugin configuration — used by @vite.config.ts
import type { SvelteKitPWAOptions } from '@vite-pwa/sveltekit';
export const SVELTEKIT_PWA_OPTIONS: SvelteKitPWAOptions = {
+ devOptions: {
+ enabled: true,
+ suppressWarnings: true
+ },
+
+ // SvelteKit-specific options
+ kit: {
+ // Include version file for proper cache invalidation
+ includeVersionFile: true
+ },
+
// Strategy: generateSW - the plugin generates a service worker automatically
// using Workbox. For a custom SW, use 'injectManifest' instead.
// Manifest configuration
// Runtime caching for API calls - use NetworkFirst so APIs are always fresh
runtimeCaching: [
{
- urlPattern: API_CACHING_PATTERNS.V1_API,
handler: RUNTIME_CACHING.HANDLER,
options: {
cacheName: RUNTIME_CACHING.CACHE_NAME,
expiration: {
- maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES,
- maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS
+ maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS,
+ maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES
}
- }
+ },
+ urlPattern: API_CACHING_PATTERNS.V1_API
},
{
- urlPattern: API_CACHING_PATTERNS.STATIC_API,
handler: RUNTIME_CACHING.HANDLER,
options: {
cacheName: RUNTIME_CACHING.CACHE_NAME,
expiration: {
- maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES,
- maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS
+ maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS,
+ maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES
}
- }
+ },
+ urlPattern: API_CACHING_PATTERNS.STATIC_API
}
]
- },
-
- devOptions: {
- enabled: true,
- suppressWarnings: true
- },
-
- // SvelteKit-specific options
- kit: {
- // Include version file for proper cache invalidation
- includeVersionFile: true
}
};
* Maps the ReasoningEffort enum values to concrete token counts for the server.
*/
export const REASONING_EFFORT_TOKENS: Record<string, number> = {
- [ReasoningEffort.LOW]: 512,
- [ReasoningEffort.MEDIUM]: 2048,
[ReasoningEffort.HIGH]: 8192,
- [ReasoningEffort.MAX]: -1 // unlimited
+ [ReasoningEffort.LOW]: 512,
+ [ReasoningEffort.MAX]: -1, // unlimited
+ [ReasoningEffort.MEDIUM]: 2048
};
*/
export const REASONING_EFFORT_LABELS: Record<string, string> = {
[ReasoningEffort.DEFAULT]: 'Default',
- [ReasoningEffort.OFF]: 'Off',
+ [ReasoningEffort.HIGH]: 'High',
[ReasoningEffort.LOW]: 'Low',
+ [ReasoningEffort.MAX]: 'Max',
[ReasoningEffort.MEDIUM]: 'Medium',
- [ReasoningEffort.HIGH]: 'High',
- [ReasoningEffort.MAX]: 'Max'
+ [ReasoningEffort.OFF]: 'Off'
};
export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
- { value: ReasoningEffort.DEFAULT, label: 'Default' },
- { value: ReasoningEffort.OFF, label: 'Off' },
- { value: ReasoningEffort.LOW, label: 'Low' },
- { value: ReasoningEffort.MEDIUM, label: 'Medium' },
- { value: ReasoningEffort.HIGH, label: 'High' },
- { value: ReasoningEffort.MAX, label: 'Max', hasInfo: true }
+ { label: 'Default', value: ReasoningEffort.DEFAULT },
+ { label: 'Off', value: ReasoningEffort.OFF },
+ { label: 'Low', value: ReasoningEffort.LOW },
+ { label: 'Medium', value: ReasoningEffort.MEDIUM },
+ { label: 'High', value: ReasoningEffort.HIGH },
+ { hasInfo: true, label: 'Max', value: ReasoningEffort.MAX }
];
// after the user clicks Add.
export const RECOMMENDED_MCP_SERVERS: RecommendedMCPServer[] = [
{
+ description: 'Search the web and fetch full page content as clean markdown.',
+ iconUrl: '/recommended-mcp/exa.ico',
id: 'exa',
name: 'Exa',
- description: 'Search the web and fetch full page content as clean markdown.',
- url: 'https://mcp.exa.ai/mcp',
- iconUrl: '/recommended-mcp/exa.ico'
+ url: 'https://mcp.exa.ai/mcp'
},
{
+ description: 'Search and browse AI models, datasets, spaces, and docs on the Hugging Face Hub.',
+ iconUrl: '/recommended-mcp/huggingface.ico',
id: 'huggingface',
name: 'Hugging Face',
- description: 'Search and browse AI models, datasets, spaces, and docs on the Hugging Face Hub.',
- url: 'https://huggingface.co/mcp',
- iconUrl: '/recommended-mcp/huggingface.ico'
+ url: 'https://huggingface.co/mcp'
},
{
- id: 'github',
- name: 'GitHub',
description: 'Search repositories, issues, pull requests and interact with code on GitHub.',
- url: 'https://api.githubcopilot.com/mcp',
- iconUrlLight: '/recommended-mcp/github-light.png',
iconUrlDark: '/recommended-mcp/github-dark.png',
- needsAuthorization: true
+ iconUrlLight: '/recommended-mcp/github-light.png',
+ id: 'github',
+ name: 'GitHub',
+ needsAuthorization: true,
+ url: 'https://api.githubcopilot.com/mcp'
},
{
+ description: 'Browse up-to-date documentation and code examples for libraries and frameworks.',
+ iconUrl: '/recommended-mcp/context7.png',
id: 'context7',
name: 'Context7',
- description: 'Browse up-to-date documentation and code examples for libraries and frameworks.',
- url: 'https://mcp.context7.com/mcp',
- iconUrl: '/recommended-mcp/context7.png'
+ url: 'https://mcp.context7.com/mcp'
}
];
/** Query params the chat routes read from the URL. */
export const URL_PARAMS = {
- /** Prompt to send on arrival. */
- QUERY: 'q',
- /** Model to select. */
- MODEL: 'model',
/** Load the selected model instead of waiting for the first message. */
LOAD: 'load',
+ /** Model to select. */
+ MODEL: 'model',
/** Start a new chat. */
- NEW_CHAT: 'new_chat'
+ NEW_CHAT: 'new_chat',
+ /** Prompt to send on arrival. */
+ QUERY: 'q'
} as const;
/** Settings section slugs — used for routes and navigation. */
export const SETTINGS_SECTION_SLUGS = {
- GENERAL: 'general',
- DISPLAY: 'display',
- SAMPLING: 'sampling',
- PENALTIES: 'penalties',
AGENTIC: 'agentic',
DEVELOPER: 'developer',
- TOOLS: 'tools',
- IMPORT_EXPORT: 'import-export'
+ DISPLAY: 'display',
+ GENERAL: 'general',
+ IMPORT_EXPORT: 'import-export',
+ PENALTIES: 'penalties',
+ SAMPLING: 'sampling',
+ TOOLS: 'tools'
} as const;
export const ROUTES = {
- /** Root — start of the app. */
- START: '#/',
- /** New chat — root with new chat query param. */
- NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
+ /** New chat — root with new chat query param. */
+ NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
+ /** Search — mobile-only full-page conversation search. */
+ SEARCH: '#/search',
/** Settings base — for dynamic settings URLs use RouterService. */
SETTINGS: '#/settings',
- /** Search — mobile-only full-page conversation search. */
- SEARCH: '#/search'
+ /** Root — start of the app. */
+ START: '#/'
} as const;
*/
export function buildSandboxToolDefinition(includeSymbolicMath: boolean): OpenAIToolDefinition {
return {
- type: ToolCallType.FUNCTION,
function: {
- name: SANDBOX_TOOL_NAME,
description: includeSymbolicMath
? `Execute JS in a sandboxed browser worker (no DOM/page access). Top-level await ok; console.log for intermediates; top-level return is captured as result.${NERDAMER_DESCRIPTION}`
: 'Execute JS in a sandboxed browser worker (no DOM/page access). Top-level await ok; console.log for intermediates; top-level return is captured as result.',
+ name: SANDBOX_TOOL_NAME,
parameters: {
- type: JsonSchemaType.OBJECT,
properties: {
code: {
- type: JsonSchemaType.STRING,
- description: 'JavaScript source to execute'
+ description: 'JavaScript source to execute',
+ type: JsonSchemaType.STRING
},
timeout_ms: {
- type: JsonSchemaType.NUMBER,
- description: `Execution timeout in milliseconds, default ${SANDBOX_TIMEOUT_MS_DEFAULT}, max ${SANDBOX_TIMEOUT_MS_MAX}`
+ description: `Execution timeout in milliseconds, default ${SANDBOX_TIMEOUT_MS_DEFAULT}, max ${SANDBOX_TIMEOUT_MS_MAX}`,
+ type: JsonSchemaType.NUMBER
}
},
- required: ['code']
+ required: ['code'],
+ type: JsonSchemaType.OBJECT
}
- }
+ },
+ type: ToolCallType.FUNCTION
};
}
* in settings field configurations to ensure consistency.
*/
export const SETTINGS_KEYS = {
- // General
- THEME: 'theme',
+ AGENTIC_MAX_TURNS: 'agenticMaxTurns',
+ ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
+ ALWAYS_SHOW_TOOL_CALL_CONTENT: 'alwaysShowToolCallContent',
API_KEY: 'apiKey',
- SYSTEM_MESSAGE: 'systemMessage',
- PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
+ AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
+ BACKEND_SAMPLING: 'backend_sampling',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
- SEND_ON_ENTER: 'sendOnEnter',
+ CUSTOM_CSS: 'customCss',
+ // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
+ CUSTOM_JSON: 'customJson',
+ DISABLE_AUTO_SCROLL: 'disableAutoScroll',
+ // Developer
+ DISABLE_REASONING_PARSING: 'disableReasoningParsing',
+ DRY_ALLOWED_LENGTH: 'dry_allowed_length',
+ DRY_BASE: 'dry_base',
+ DRY_MULTIPLIER: 'dry_multiplier',
+ DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
+ DYNATEMP_EXPONENT: 'dynatemp_exponent',
+ DYNATEMP_RANGE: 'dynatemp_range',
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
- PDF_AS_IMAGE: 'pdfAsImage',
- TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
- TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
- TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
+ EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
+ FREQUENCY_PENALTY: 'frequency_penalty',
+ FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
+ JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
+ MAX_TOKENS: 'max_tokens',
+ MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
+ // MCP
+ MCP_SERVERS: 'mcpServers',
+ MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
+ MIN_P: 'min_p',
+ PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
+ PDF_AS_IMAGE: 'pdfAsImage',
+ // Performance
+ PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
+ PRESENCE_PENALTY: 'presence_penalty',
+ RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
+ // Penalties
+ REPEAT_LAST_N: 'repeat_last_n',
+ REPEAT_PENALTY: 'repeat_penalty',
+ SAMPLERS: 'samplers',
+ SEND_ON_ENTER: 'sendOnEnter',
+ SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
+ SHOW_BUILD_VERSION: 'showBuildVersion',
+ SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
- SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
- SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
- AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
- RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
- DISABLE_AUTO_SCROLL: 'disableAutoScroll',
- ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
- FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
- SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
- SHOW_BUILD_VERSION: 'showBuildVersion',
- SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
+ SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
+ SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
- MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
+ SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
+ SYMBOLIC_MATH_ENABLED: 'symbolicMathEnabled',
+ SYSTEM_MESSAGE: 'systemMessage',
// Sampling
TEMPERATURE: 'temperature',
- DYNATEMP_RANGE: 'dynatemp_range',
- DYNATEMP_EXPONENT: 'dynatemp_exponent',
+ // General
+ THEME: 'theme',
+ TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
+ TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
+ TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
TOP_K: 'top_k',
TOP_P: 'top_p',
- MIN_P: 'min_p',
- XTC_PROBABILITY: 'xtc_probability',
- XTC_THRESHOLD: 'xtc_threshold',
TYP_P: 'typ_p',
- MAX_TOKENS: 'max_tokens',
- SAMPLERS: 'samplers',
- BACKEND_SAMPLING: 'backend_sampling',
- // Penalties
- REPEAT_LAST_N: 'repeat_last_n',
- REPEAT_PENALTY: 'repeat_penalty',
- PRESENCE_PENALTY: 'presence_penalty',
- FREQUENCY_PENALTY: 'frequency_penalty',
- DRY_MULTIPLIER: 'dry_multiplier',
- DRY_BASE: 'dry_base',
- DRY_ALLOWED_LENGTH: 'dry_allowed_length',
- DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
- // MCP
- MCP_SERVERS: 'mcpServers',
- MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
- AGENTIC_MAX_TURNS: 'agenticMaxTurns',
- ALWAYS_SHOW_TOOL_CALL_CONTENT: 'alwaysShowToolCallContent',
- // Performance
- PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
- // Developer
- DISABLE_REASONING_PARSING: 'disableReasoningParsing',
- EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
- SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
- JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
- SYMBOLIC_MATH_ENABLED: 'symbolicMathEnabled',
- // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
- CUSTOM_JSON: 'customJson',
- CUSTOM_CSS: 'customCss'
+ XTC_PROBABILITY: 'xtc_probability',
+ XTC_THRESHOLD: 'xtc_threshold'
} as const;
-import { ColorMode } from '$lib/enums/ui.enums';
-import { SettingsFieldType } from '$lib/enums/settings.enums';
-import { SyncableParameterType } from '$lib/enums';
+import { CLI_FLAGS } from './cli-flags';
+import { DEFAULT_MCP_CONFIG } from './mcp';
+import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
+import { SETTINGS_KEYS } from './settings-keys';
+import { TITLE_GENERATION } from './title-generation';
+import {
+ FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
+ FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH
+} from './working-directory';
import {
- Funnel,
AlertTriangle,
Code,
- Monitor,
- ListRestart,
- Sliders,
- PencilRuler,
Database,
+ Funnel,
+ ListRestart,
+ Monitor,
Monitor as MonitorIcon,
- Sun,
- Moon
+ Moon,
+ PencilRuler,
+ Sliders,
+ Sun
} from '@lucide/svelte';
-import type { Component } from 'svelte';
+import { SyncableParameterType } from '$lib/enums';
+import { SettingsFieldType } from '$lib/enums/settings.enums';
+import { ColorMode } from '$lib/enums/ui.enums';
import type {
SettingsConfigValue,
- SyncableParameter,
SettingsEntry,
- SettingsSectionTitle,
+ SettingsSection,
SettingsSectionEntry,
- SettingsSection
+ SettingsSectionTitle,
+ SyncableParameter
} from '$lib/types';
-import { CLI_FLAGS } from './cli-flags';
-import { DEFAULT_MCP_CONFIG } from './mcp';
-import {
- FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
- FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
-} from './working-directory';
-import { SETTINGS_KEYS } from './settings-keys';
-import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
-import { TITLE_GENERATION } from './title-generation';
+import type { Component } from 'svelte';
export const SETTINGS_SECTION_TITLES = {
- GENERAL: 'General',
- DISPLAY: 'Display',
- SAMPLING: 'Sampling',
- PENALTIES: 'Penalties',
AGENTIC: 'Agentic',
- TOOLS: 'Tools',
+ DEVELOPER: 'Developer',
+ DISPLAY: 'Display',
+ GENERAL: 'General',
IMPORT_EXPORT: 'Import/Export',
- DEVELOPER: 'Developer'
+ PENALTIES: 'Penalties',
+ SAMPLING: 'Sampling',
+ TOOLS: 'Tools'
} as const;
const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [
- { title: SETTINGS_SECTION_TITLES.TOOLS, slug: SETTINGS_SECTION_SLUGS.TOOLS, icon: PencilRuler },
+ { icon: PencilRuler, slug: SETTINGS_SECTION_SLUGS.TOOLS, title: SETTINGS_SECTION_TITLES.TOOLS },
{
- title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
+ icon: Database,
slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT,
- icon: Database
+ title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT
}
];
-
const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [
- { value: ColorMode.SYSTEM, label: 'System', icon: MonitorIcon },
- { value: ColorMode.LIGHT, label: 'Light', icon: Sun },
- { value: ColorMode.DARK, label: 'Dark', icon: Moon }
+ { icon: MonitorIcon, label: 'System', value: ColorMode.SYSTEM },
+ { icon: Sun, label: 'Light', value: ColorMode.LIGHT },
+ { icon: Moon, label: 'Dark', value: ColorMode.DARK }
];
-
// Shared options for the title-generation radio group. Both paired registry entries
// (USE_FIRST_LINE, USE_LLM) reference this list so labels stay in lockstep.
const TITLE_GENERATION_RADIO_OPTIONS: Array<{
isExperimental?: boolean;
}> = [
{
- value: 'firstLine',
+ key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
label: 'Use first non-empty line for the conversation title',
- key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE
+ value: 'firstLine'
},
{
- value: 'llm',
- label: 'Generate title with LLM',
+ isExperimental: true,
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
- isExperimental: true
+ label: 'Generate title with LLM',
+ value: 'llm'
}
];
-
// Common shape for the conversation title radio entry.
const TITLE_GENERATION_BASE = {
- type: SettingsFieldType.RADIO,
+ radioOptions: TITLE_GENERATION_RADIO_OPTIONS,
section: SETTINGS_SECTION_SLUGS.GENERAL,
- radioOptions: TITLE_GENERATION_RADIO_OPTIONS
+ type: SettingsFieldType.RADIO
} as const;
-
const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
- [SETTINGS_SECTION_SLUGS.GENERAL]: {
- title: SETTINGS_SECTION_TITLES.GENERAL,
- slug: SETTINGS_SECTION_SLUGS.GENERAL,
- icon: Sliders,
+ [SETTINGS_SECTION_SLUGS.AGENTIC]: {
+ icon: ListRestart,
settings: [
{
- key: SETTINGS_KEYS.THEME,
- label: 'Theme',
- help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
- defaultValue: ColorMode.SYSTEM,
- type: SettingsFieldType.SELECT,
- section: SETTINGS_SECTION_SLUGS.GENERAL,
- options: COLOR_MODE_OPTIONS
- },
- {
- key: SETTINGS_KEYS.API_KEY,
- label: 'API Key',
- help: `Set the API Key if you are using <code> ${CLI_FLAGS.API_KEY} </code> option for the server.`,
- defaultValue: '',
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ defaultValue: 10,
+ help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
+ isPositiveInteger: true,
+ key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
+ label: 'Agentic turns',
+ section: SETTINGS_SECTION_SLUGS.AGENTIC,
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.SYSTEM_MESSAGE,
- label: 'System Message',
- help: 'The starting message that defines how model should behave.',
- defaultValue: '',
- type: SettingsFieldType.TEXTAREA,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
+ help: 'Timeout for individual MCP tool calls.',
+ isPositiveInteger: true,
+ key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
+ label: 'MCP request timeout (seconds)',
+ section: SETTINGS_SECTION_SLUGS.AGENTIC,
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
- label: 'Paste long text to file length',
- help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
- defaultValue: 2500,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.GENERAL
- },
+ defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
+ help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
+ isPositiveInteger: true,
+ key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
+ label: 'Mention search depth',
+ max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
+ min: 1,
+ placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
+ section: SETTINGS_SECTION_SLUGS.AGENTIC,
+ type: SettingsFieldType.INPUT
+ }
+ ],
+ slug: SETTINGS_SECTION_SLUGS.AGENTIC,
+ title: SETTINGS_SECTION_TITLES.AGENTIC
+ },
+ [SETTINGS_SECTION_SLUGS.DEVELOPER]: {
+ icon: Code,
+ settings: [
{
- key: SETTINGS_KEYS.SEND_ON_ENTER,
- label: 'Send message on Enter',
- help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ defaultValue: false,
+ help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
+ key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
+ label: 'Pre-fill KV cache after response',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
- label: 'Show microphone on empty input',
- help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.GENERAL,
- isExperimental: true
+ help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
+ key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
+ label: 'Disable reasoning content parsing',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
- label: 'Enable "Continue" button',
- help: 'Enable "Continue" button for assistant messages, including reasoning models.',
defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.GENERAL,
- isExperimental: true
+ help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
+ key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
+ label: 'Exclude reasoning from context',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- ...TITLE_GENERATION_BASE,
- key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
- label: 'Conversation title',
- help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
- defaultValue: true
+ defaultValue: false,
+ help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
+ key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
+ label: 'Enable raw output toggle',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
- label: 'LLM title generation prompt',
- help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
- defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
- type: SettingsFieldType.TEXTAREA,
- section: SETTINGS_SECTION_SLUGS.GENERAL,
- dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM
+ defaultValue: false,
+ help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.',
+ key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
+ label: 'JavaScript sandbox tool',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
- label: 'Copy text attachments as plain text',
- help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ dependsOn: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
+ help: 'Pre-load nerdamer in the sandbox for symbolic computation: simplify, diff, integrate, solve, and more. Requires "JavaScript sandbox tool" to be enabled.',
+ key: SETTINGS_KEYS.SYMBOLIC_MATH_ENABLED,
+ label: 'Symbolic math (nerdamer)',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.PDF_AS_IMAGE,
- label: 'Parse PDF as image',
- help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ defaultValue: '',
+ help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
+ key: SETTINGS_KEYS.CUSTOM_JSON,
+ label: 'Custom JSON',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.TEXTAREA
},
{
- key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
- label: 'Maximum image resolution (megapixels)',
- help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.',
- defaultValue: 0,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.GENERAL
+ defaultValue: '',
+ help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
+ key: SETTINGS_KEYS.CUSTOM_CSS,
+ label: 'Custom CSS',
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ type: SettingsFieldType.TEXTAREA
}
- ]
+ ],
+ slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
+ title: SETTINGS_SECTION_TITLES.DEVELOPER
},
[SETTINGS_SECTION_SLUGS.DISPLAY]: {
- title: SETTINGS_SECTION_TITLES.DISPLAY,
- slug: SETTINGS_SECTION_SLUGS.DISPLAY,
icon: Monitor,
settings: [
{
+ defaultValue: true,
+ help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
- help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ dependsOn: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
+ help: 'Display per-turn statistics (tokens, duration) under each turn in agentic responses. Shown only when "Show message generation statistics" is enabled.',
key: SETTINGS_KEYS.SHOW_AGENTIC_TURN_STATS,
label: 'Show statistics for individual agentic turns',
- help: 'Display per-turn statistics (tokens, duration) under each turn in agentic responses. Shown only when "Show message generation statistics" is enabled.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
- dependsOn: SETTINGS_KEYS.SHOW_MESSAGE_STATS
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: true,
+ help: 'Expand thought process by default when generating messages.',
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
- help: 'Expand thought process by default when generating messages.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
key: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT,
label: 'Always show tool call content',
- help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
label: 'Render content as raw text',
- help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Always display code blocks at their full natural height, overriding any height limits.',
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
- help: 'Always display code blocks at their full natural height, overriding any height limits.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
- help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
- help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
- help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: true,
+ help: 'Display quantization badges (e.g. Q8_0, Q4_K_M) next to model names throughout the interface.',
key: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION,
label: 'Show model quantization information',
- help: 'Display quantization badges (e.g. Q8_0, Q4_K_M) next to model names throughout the interface.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: true,
+ help: 'Display model tags (e.g. "vision", "reasoning") next to model names throughout the interface.',
key: SETTINGS_KEYS.SHOW_MODEL_TAGS,
label: 'Show model tags',
- help: 'Display model tags (e.g. "vision", "reasoning") next to model names throughout the interface.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Display the current build version in the bottom-right corner of the interface.',
key: SETTINGS_KEYS.SHOW_BUILD_VERSION,
label: 'Show build version information',
- help: 'Display the current build version in the bottom-right corner of the interface.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: false,
+ help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
label: 'Show full path in mentions',
- help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
+ section: SETTINGS_SECTION_SLUGS.DISPLAY,
+ type: SettingsFieldType.CHECKBOX
}
- ]
+ ],
+ slug: SETTINGS_SECTION_SLUGS.DISPLAY,
+ title: SETTINGS_SECTION_TITLES.DISPLAY
},
- [SETTINGS_SECTION_SLUGS.SAMPLING]: {
- title: SETTINGS_SECTION_TITLES.SAMPLING,
- slug: SETTINGS_SECTION_SLUGS.SAMPLING,
- icon: Funnel,
+ [SETTINGS_SECTION_SLUGS.GENERAL]: {
+ icon: Sliders,
settings: [
{
- key: SETTINGS_KEYS.TEMPERATURE,
- label: 'Temperature',
- help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.TEMPERATURE,
- paramType: SyncableParameterType.NUMBER
- }
- },
- {
- key: SETTINGS_KEYS.DYNATEMP_RANGE,
- label: 'Dynamic temperature range',
- help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.DYNATEMP_RANGE,
- paramType: SyncableParameterType.NUMBER
- }
+ defaultValue: ColorMode.SYSTEM,
+ help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
+ key: SETTINGS_KEYS.THEME,
+ label: 'Theme',
+ options: COLOR_MODE_OPTIONS,
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.SELECT
},
{
- key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
- label: 'Dynamic temperature exponent',
- help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT,
- paramType: SyncableParameterType.NUMBER
- }
+ defaultValue: '',
+ help: `Set the API Key if you are using <code> ${CLI_FLAGS.API_KEY} </code> option for the server.`,
+ key: SETTINGS_KEYS.API_KEY,
+ label: 'API Key',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.TOP_K,
- label: 'Top K',
- help: 'Keeps only k top tokens.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: { serverKey: SETTINGS_KEYS.TOP_K, paramType: SyncableParameterType.NUMBER }
+ defaultValue: '',
+ help: 'The starting message that defines how model should behave.',
+ key: SETTINGS_KEYS.SYSTEM_MESSAGE,
+ label: 'System Message',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.TEXTAREA
},
{
- key: SETTINGS_KEYS.TOP_P,
- label: 'Top P',
- help: 'Limits tokens to those that together have a cumulative probability of at least p',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: { serverKey: SETTINGS_KEYS.TOP_P, paramType: SyncableParameterType.NUMBER }
+ defaultValue: 2500,
+ help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
+ key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
+ label: 'Paste long text to file length',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.MIN_P,
- label: 'Min P',
- help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: { serverKey: SETTINGS_KEYS.MIN_P, paramType: SyncableParameterType.NUMBER }
+ defaultValue: true,
+ help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
+ key: SETTINGS_KEYS.SEND_ON_ENTER,
+ label: 'Send message on Enter',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.XTC_PROBABILITY,
- label: 'XTC probability',
- help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.XTC_PROBABILITY,
- paramType: SyncableParameterType.NUMBER
- }
+ defaultValue: false,
+ help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
+ isExperimental: true,
+ key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
+ label: 'Show microphone on empty input',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.XTC_THRESHOLD,
- label: 'XTC threshold',
- help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.XTC_THRESHOLD,
- paramType: SyncableParameterType.NUMBER
- }
+ defaultValue: false,
+ help: 'Enable "Continue" button for assistant messages, including reasoning models.',
+ isExperimental: true,
+ key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
+ label: 'Enable "Continue" button',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.TYP_P,
- label: 'Typical P',
- help: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: { serverKey: SETTINGS_KEYS.TYP_P, paramType: SyncableParameterType.NUMBER }
+ ...TITLE_GENERATION_BASE,
+ defaultValue: true,
+ help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
+ key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
+ label: 'Conversation title'
},
{
- key: SETTINGS_KEYS.MAX_TOKENS,
- label: 'Max tokens',
- help: 'The maximum number of token per output. Use -1 for infinite (no limit).',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: {
- serverKey: SETTINGS_KEYS.MAX_TOKENS,
- paramType: SyncableParameterType.NUMBER
- }
+ defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
+ dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
+ help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
+ key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
+ label: 'LLM title generation prompt',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.TEXTAREA
},
{
- key: SETTINGS_KEYS.SAMPLERS,
- label: 'Samplers',
- help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
- defaultValue: '',
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.SAMPLING,
- sync: { serverKey: SETTINGS_KEYS.SAMPLERS, paramType: SyncableParameterType.STRING }
+ defaultValue: false,
+ help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
+ key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
+ label: 'Copy text attachments as plain text',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.CHECKBOX
},
{
- key: SETTINGS_KEYS.BACKEND_SAMPLING,
- label: 'Backend sampling',
- help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.SAMPLING
+ help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
+ key: SETTINGS_KEYS.PDF_AS_IMAGE,
+ label: 'Parse PDF as image',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: 0,
+ help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.',
+ key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
+ label: 'Maximum image resolution (megapixels)',
+ section: SETTINGS_SECTION_SLUGS.GENERAL,
+ type: SettingsFieldType.INPUT
}
- ]
+ ],
+ slug: SETTINGS_SECTION_SLUGS.GENERAL,
+ title: SETTINGS_SECTION_TITLES.GENERAL
},
[SETTINGS_SECTION_SLUGS.PENALTIES]: {
- title: SETTINGS_SECTION_TITLES.PENALTIES,
- slug: SETTINGS_SECTION_SLUGS.PENALTIES,
icon: AlertTriangle,
settings: [
{
+ defaultValue: undefined,
+ help: 'Last n tokens to consider for penalizing repetition',
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
- help: 'Last n tokens to consider for penalizing repetition',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.REPEAT_LAST_N,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.REPEAT_LAST_N
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'Controls the repetition of token sequences in the generated text',
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
- help: 'Controls the repetition of token sequences in the generated text',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.REPEAT_PENALTY,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.REPEAT_PENALTY
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'Limits tokens based on whether they appear in the output or not.',
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
- help: 'Limits tokens based on whether they appear in the output or not.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.PRESENCE_PENALTY,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.PRESENCE_PENALTY
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'Limits tokens based on how often they appear in the output.',
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
- help: 'Limits tokens based on how often they appear in the output.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
- help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.DRY_MULTIPLIER,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.DRY_MULTIPLIER
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
- help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
- sync: { serverKey: SETTINGS_KEYS.DRY_BASE, paramType: SyncableParameterType.NUMBER }
+ sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.DRY_BASE },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
- help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH
+ },
+ type: SettingsFieldType.INPUT
},
{
+ defaultValue: undefined,
+ help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
- help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
- defaultValue: undefined,
- type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
- serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
- paramType: SyncableParameterType.NUMBER
- }
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N
+ },
+ type: SettingsFieldType.INPUT
}
- ]
+ ],
+ slug: SETTINGS_SECTION_SLUGS.PENALTIES,
+ title: SETTINGS_SECTION_TITLES.PENALTIES
},
- [SETTINGS_SECTION_SLUGS.AGENTIC]: {
- title: SETTINGS_SECTION_TITLES.AGENTIC,
- slug: SETTINGS_SECTION_SLUGS.AGENTIC,
- icon: ListRestart,
+ [SETTINGS_SECTION_SLUGS.SAMPLING]: {
+ icon: Funnel,
settings: [
{
- key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
- label: 'Agentic turns',
- help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
- defaultValue: 10,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.AGENTIC,
- isPositiveInteger: true
+ defaultValue: undefined,
+ help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
+ key: SETTINGS_KEYS.TEMPERATURE,
+ label: 'Temperature',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.TEMPERATURE
+ },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
- label: 'MCP request timeout (seconds)',
- help: 'Timeout for individual MCP tool calls.',
- defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.AGENTIC,
- isPositiveInteger: true
+ defaultValue: undefined,
+ help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
+ key: SETTINGS_KEYS.DYNATEMP_RANGE,
+ label: 'Dynamic temperature range',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.DYNATEMP_RANGE
+ },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
- label: 'Mention search depth',
- help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
- defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
- placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
- min: 1,
- max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
- type: SettingsFieldType.INPUT,
- section: SETTINGS_SECTION_SLUGS.AGENTIC,
- isPositiveInteger: true
- }
- ]
- },
- [SETTINGS_SECTION_SLUGS.DEVELOPER]: {
- title: SETTINGS_SECTION_TITLES.DEVELOPER,
- slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
- icon: Code,
- settings: [
+ defaultValue: undefined,
+ help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
+ key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
+ label: 'Dynamic temperature exponent',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT
+ },
+ type: SettingsFieldType.INPUT
+ },
{
- key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
- label: 'Pre-fill KV cache after response',
- help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'Keeps only k top tokens.',
+ key: SETTINGS_KEYS.TOP_K,
+ label: 'Top K',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.TOP_K },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
- label: 'Disable reasoning content parsing',
- help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'Limits tokens to those that together have a cumulative probability of at least p',
+ key: SETTINGS_KEYS.TOP_P,
+ label: 'Top P',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.TOP_P },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
- label: 'Exclude reasoning from context',
- help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
+ key: SETTINGS_KEYS.MIN_P,
+ label: 'Min P',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.MIN_P },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
- label: 'Enable raw output toggle',
- help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
+ key: SETTINGS_KEYS.XTC_PROBABILITY,
+ label: 'XTC probability',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.XTC_PROBABILITY
+ },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
- label: 'JavaScript sandbox tool',
- help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
+ key: SETTINGS_KEYS.XTC_THRESHOLD,
+ label: 'XTC threshold',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.XTC_THRESHOLD
+ },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.SYMBOLIC_MATH_ENABLED,
- label: 'Symbolic math (nerdamer)',
- help: 'Pre-load nerdamer in the sandbox for symbolic computation: simplify, diff, integrate, solve, and more. Requires "JavaScript sandbox tool" to be enabled.',
- defaultValue: false,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER,
- dependsOn: SETTINGS_KEYS.JS_SANDBOX_ENABLED
+ defaultValue: undefined,
+ help: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
+ key: SETTINGS_KEYS.TYP_P,
+ label: 'Typical P',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.TYP_P },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.CUSTOM_JSON,
- label: 'Custom JSON',
- help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
- defaultValue: '',
- type: SettingsFieldType.TEXTAREA,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ defaultValue: undefined,
+ help: 'The maximum number of token per output. Use -1 for infinite (no limit).',
+ key: SETTINGS_KEYS.MAX_TOKENS,
+ label: 'Max tokens',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: {
+ paramType: SyncableParameterType.NUMBER,
+ serverKey: SETTINGS_KEYS.MAX_TOKENS
+ },
+ type: SettingsFieldType.INPUT
},
{
- key: SETTINGS_KEYS.CUSTOM_CSS,
- label: 'Custom CSS',
- help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
defaultValue: '',
- type: SettingsFieldType.TEXTAREA,
- section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
+ key: SETTINGS_KEYS.SAMPLERS,
+ label: 'Samplers',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ sync: { paramType: SyncableParameterType.STRING, serverKey: SETTINGS_KEYS.SAMPLERS },
+ type: SettingsFieldType.INPUT
+ },
+ {
+ defaultValue: false,
+ help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
+ key: SETTINGS_KEYS.BACKEND_SAMPLING,
+ label: 'Backend sampling',
+ section: SETTINGS_SECTION_SLUGS.SAMPLING,
+ type: SettingsFieldType.CHECKBOX
}
- ]
+ ],
+ slug: SETTINGS_SECTION_SLUGS.SAMPLING,
+ title: SETTINGS_SECTION_TITLES.SAMPLING
}
} as const;
-
const NON_UI_SETTINGS: SettingsEntry[] = [
{
+ defaultValue: true,
+ help: 'Display the system message at the top of each conversation.',
key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
label: 'Show system message',
- help: 'Display the system message at the top of each conversation.',
- defaultValue: true,
type: SettingsFieldType.CHECKBOX
},
{
+ defaultValue: '[]',
+ help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
key: SETTINGS_KEYS.MCP_SERVERS,
label: 'MCP servers',
- help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
- defaultValue: '[]',
type: SettingsFieldType.INPUT
},
{
+ defaultValue: false,
+ help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
label: 'Generate title with LLM',
- help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
- defaultValue: false,
type: SettingsFieldType.CHECKBOX
}
// {
function getAllSettings(): SettingsEntry[] {
const result: SettingsEntry[] = [];
+
for (const section of Object.values(SETTINGS_REGISTRY)) {
result.push(...section.settings);
}
result.push(...NON_UI_SETTINGS);
+
return result;
}
/** Sidebar sections + field configs (as consumed by UI). */
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
...Object.values(SETTINGS_REGISTRY).map((section) => ({
- title: section.title,
- slug: section.slug,
- icon: section.icon,
fields: section.settings.map((s) => ({
- key: s.key,
- label: s.label,
- type: s.type,
+ dependsOn: s.dependsOn,
+ help: s.help,
isExperimental: s.isExperimental,
isPositiveInteger: s.isPositiveInteger,
- placeholder: s.placeholder,
- min: s.min,
+ key: s.key,
+ label: s.label,
max: s.max,
- dependsOn: s.dependsOn,
- help: s.help,
+ min: s.min,
options: s.options,
- radioOptions: s.radioOptions
- }))
+ placeholder: s.placeholder,
+ radioOptions: s.radioOptions,
+ type: s.type
+ })),
+ icon: section.icon,
+ slug: section.slug,
+ title: section.title
})),
...STANDALONE_SECTIONS
];
export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
.filter((s) => s.sync !== undefined)
.map((s) => ({
+ canSync: true,
key: s.key,
serverKey: s.sync!.serverKey,
- type: s.sync!.paramType,
- canSync: true
+ type: s.sync!.paramType
}));
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
FileTypeImage,
FileTypePdf,
FileTypeText,
+ MimeTypeApplication,
MimeTypeAudio,
- MimeTypeVideo,
MimeTypeImage,
- MimeTypeApplication,
- MimeTypeText
+ MimeTypeText,
+ MimeTypeVideo
} from '$lib/enums';
import { FileExtensionVideo, FileTypeVideo } from '$lib/enums/files.enums';
} as const;
export const IMAGE_FILE_TYPES = {
+ [FileTypeImage.GIF]: {
+ extensions: [FileExtensionImage.GIF],
+ mimeTypes: [MimeTypeImage.GIF]
+ },
+ [FileTypeImage.HEIC]: {
+ extensions: [FileExtensionImage.HEIC, FileExtensionImage.HEIF],
+ mimeTypes: [MimeTypeImage.HEIC, MimeTypeImage.HEIF]
+ },
[FileTypeImage.JPEG]: {
extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG],
mimeTypes: [MimeTypeImage.JPEG]
extensions: [FileExtensionImage.PNG],
mimeTypes: [MimeTypeImage.PNG]
},
- [FileTypeImage.GIF]: {
- extensions: [FileExtensionImage.GIF],
- mimeTypes: [MimeTypeImage.GIF]
- },
- [FileTypeImage.WEBP]: {
- extensions: [FileExtensionImage.WEBP],
- mimeTypes: [MimeTypeImage.WEBP]
- },
[FileTypeImage.SVG]: {
extensions: [FileExtensionImage.SVG],
mimeTypes: [MimeTypeImage.SVG]
},
- [FileTypeImage.HEIC]: {
- extensions: [FileExtensionImage.HEIC, FileExtensionImage.HEIF],
- mimeTypes: [MimeTypeImage.HEIC, MimeTypeImage.HEIF]
+ [FileTypeImage.WEBP]: {
+ extensions: [FileExtensionImage.WEBP],
+ mimeTypes: [MimeTypeImage.WEBP]
}
} as const;
} as const;
export const TEXT_FILE_TYPES = {
- [FileTypeText.PLAIN_TEXT]: {
- extensions: [FileExtensionText.TXT],
- mimeTypes: [MimeTypeText.PLAIN]
- },
- [FileTypeText.MARKDOWN]: {
- extensions: [FileExtensionText.MD],
- mimeTypes: [MimeTypeText.MARKDOWN]
- },
[FileTypeText.ASCIIDOC]: {
extensions: [FileExtensionText.ADOC],
mimeTypes: [MimeTypeText.ASCIIDOC]
},
- [FileTypeText.JAVASCRIPT]: {
- extensions: [FileExtensionText.JS],
- mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP]
- },
- [FileTypeText.TYPESCRIPT]: {
- extensions: [FileExtensionText.TS],
- mimeTypes: [MimeTypeText.TYPESCRIPT]
+ [FileTypeText.BIBTEX]: {
+ extensions: [FileExtensionText.BIB],
+ mimeTypes: [MimeTypeText.BIBTEX]
},
- [FileTypeText.JSX]: {
- extensions: [FileExtensionText.JSX],
- mimeTypes: [MimeTypeText.JSX]
+ [FileTypeText.CPP]: {
+ extensions: [
+ FileExtensionText.CPP,
+ FileExtensionText.C,
+ FileExtensionText.H,
+ FileExtensionText.HPP
+ ],
+ mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR]
},
- [FileTypeText.TSX]: {
- extensions: [FileExtensionText.TSX],
- mimeTypes: [MimeTypeText.TSX]
+ [FileTypeText.CSHARP]: {
+ extensions: [FileExtensionText.CS],
+ mimeTypes: [MimeTypeText.CSHARP]
},
[FileTypeText.CSS]: {
extensions: [FileExtensionText.CSS],
mimeTypes: [MimeTypeText.CSS]
},
+ [FileTypeText.CSV]: {
+ extensions: [FileExtensionText.CSV],
+ mimeTypes: [MimeTypeText.CSV]
+ },
+ [FileTypeText.CUDA]: {
+ extensions: [FileExtensionText.CU, FileExtensionText.CUH],
+ mimeTypes: [MimeTypeText.CUDA]
+ },
+ [FileTypeText.DART]: {
+ extensions: [FileExtensionText.DART],
+ mimeTypes: [MimeTypeText.DART]
+ },
+ [FileTypeText.GO]: {
+ extensions: [FileExtensionText.GO],
+ mimeTypes: [MimeTypeText.GO]
+ },
+ [FileTypeText.HASKELL]: {
+ extensions: [FileExtensionText.HS],
+ mimeTypes: [MimeTypeText.HASKELL]
+ },
[FileTypeText.HTML]: {
extensions: [FileExtensionText.HTML, FileExtensionText.HTM],
mimeTypes: [MimeTypeText.HTML]
},
+ [FileTypeText.JAVA]: {
+ extensions: [FileExtensionText.JAVA],
+ mimeTypes: [MimeTypeText.JAVA]
+ },
+ [FileTypeText.JAVASCRIPT]: {
+ extensions: [FileExtensionText.JS],
+ mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP]
+ },
[FileTypeText.JSON]: {
extensions: [FileExtensionText.JSON],
mimeTypes: [MimeTypeText.JSON]
},
- [FileTypeText.XML]: {
- extensions: [FileExtensionText.XML],
- mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP]
+ [FileTypeText.JSX]: {
+ extensions: [FileExtensionText.JSX],
+ mimeTypes: [MimeTypeText.JSX]
},
- [FileTypeText.YAML]: {
- extensions: [FileExtensionText.YAML, FileExtensionText.YML],
- mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP]
+ [FileTypeText.KOTLIN]: {
+ extensions: [FileExtensionText.KT],
+ mimeTypes: [MimeTypeText.KOTLIN]
},
- [FileTypeText.CSV]: {
- extensions: [FileExtensionText.CSV],
- mimeTypes: [MimeTypeText.CSV]
+ [FileTypeText.LATEX]: {
+ extensions: [FileExtensionText.TEX],
+ mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP]
},
[FileTypeText.LOG]: {
extensions: [FileExtensionText.LOG],
mimeTypes: [MimeTypeText.PLAIN]
},
- [FileTypeText.PYTHON]: {
- extensions: [FileExtensionText.PY],
- mimeTypes: [MimeTypeText.PYTHON]
- },
- [FileTypeText.JAVA]: {
- extensions: [FileExtensionText.JAVA],
- mimeTypes: [MimeTypeText.JAVA]
- },
- [FileTypeText.CPP]: {
- extensions: [
- FileExtensionText.CPP,
- FileExtensionText.C,
- FileExtensionText.H,
- FileExtensionText.HPP
- ],
- mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR]
+ [FileTypeText.MARKDOWN]: {
+ extensions: [FileExtensionText.MD],
+ mimeTypes: [MimeTypeText.MARKDOWN]
},
[FileTypeText.PHP]: {
extensions: [FileExtensionText.PHP],
mimeTypes: [MimeTypeText.PHP]
},
+ [FileTypeText.PLAIN_TEXT]: {
+ extensions: [FileExtensionText.TXT],
+ mimeTypes: [MimeTypeText.PLAIN]
+ },
+ [FileTypeText.PROPERTIES]: {
+ extensions: [FileExtensionText.PROPERTIES],
+ mimeTypes: [MimeTypeText.PROPERTIES]
+ },
+ [FileTypeText.PYTHON]: {
+ extensions: [FileExtensionText.PY],
+ mimeTypes: [MimeTypeText.PYTHON]
+ },
+ [FileTypeText.R]: {
+ extensions: [FileExtensionText.R],
+ mimeTypes: [MimeTypeText.R]
+ },
[FileTypeText.RUBY]: {
extensions: [FileExtensionText.RB],
mimeTypes: [MimeTypeText.RUBY]
},
- [FileTypeText.GO]: {
- extensions: [FileExtensionText.GO],
- mimeTypes: [MimeTypeText.GO]
- },
[FileTypeText.RUST]: {
extensions: [FileExtensionText.RS],
mimeTypes: [MimeTypeText.RUST]
},
+ [FileTypeText.SCALA]: {
+ extensions: [FileExtensionText.SCALA],
+ mimeTypes: [MimeTypeText.SCALA]
+ },
[FileTypeText.SHELL]: {
extensions: [FileExtensionText.SH, FileExtensionText.BAT],
mimeTypes: [MimeTypeText.SHELL, MimeTypeText.BAT]
extensions: [FileExtensionText.SQL],
mimeTypes: [MimeTypeText.SQL]
},
- [FileTypeText.R]: {
- extensions: [FileExtensionText.R],
- mimeTypes: [MimeTypeText.R]
- },
- [FileTypeText.SCALA]: {
- extensions: [FileExtensionText.SCALA],
- mimeTypes: [MimeTypeText.SCALA]
- },
- [FileTypeText.KOTLIN]: {
- extensions: [FileExtensionText.KT],
- mimeTypes: [MimeTypeText.KOTLIN]
+ [FileTypeText.SVELTE]: {
+ extensions: [FileExtensionText.SVELTE],
+ mimeTypes: [MimeTypeText.SVELTE]
},
[FileTypeText.SWIFT]: {
extensions: [FileExtensionText.SWIFT],
mimeTypes: [MimeTypeText.SWIFT]
},
- [FileTypeText.DART]: {
- extensions: [FileExtensionText.DART],
- mimeTypes: [MimeTypeText.DART]
+ [FileTypeText.TSX]: {
+ extensions: [FileExtensionText.TSX],
+ mimeTypes: [MimeTypeText.TSX]
+ },
+ [FileTypeText.TYPESCRIPT]: {
+ extensions: [FileExtensionText.TS],
+ mimeTypes: [MimeTypeText.TYPESCRIPT]
},
[FileTypeText.VUE]: {
extensions: [FileExtensionText.VUE],
mimeTypes: [MimeTypeText.VUE]
},
- [FileTypeText.SVELTE]: {
- extensions: [FileExtensionText.SVELTE],
- mimeTypes: [MimeTypeText.SVELTE]
- },
- [FileTypeText.LATEX]: {
- extensions: [FileExtensionText.TEX],
- mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP]
- },
- [FileTypeText.BIBTEX]: {
- extensions: [FileExtensionText.BIB],
- mimeTypes: [MimeTypeText.BIBTEX]
- },
- [FileTypeText.CUDA]: {
- extensions: [FileExtensionText.CU, FileExtensionText.CUH],
- mimeTypes: [MimeTypeText.CUDA]
- },
[FileTypeText.VULKAN]: {
extensions: [FileExtensionText.COMP],
mimeTypes: [MimeTypeText.PLAIN]
},
- [FileTypeText.HASKELL]: {
- extensions: [FileExtensionText.HS],
- mimeTypes: [MimeTypeText.HASKELL]
- },
- [FileTypeText.CSHARP]: {
- extensions: [FileExtensionText.CS],
- mimeTypes: [MimeTypeText.CSHARP]
+ [FileTypeText.XML]: {
+ extensions: [FileExtensionText.XML],
+ mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP]
},
- [FileTypeText.PROPERTIES]: {
- extensions: [FileExtensionText.PROPERTIES],
- mimeTypes: [MimeTypeText.PROPERTIES]
+ [FileTypeText.YAML]: {
+ extensions: [FileExtensionText.YAML, FileExtensionText.YML],
+ mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP]
}
} as const;
* so an author <style> stays scoped to that root and can not reach the page.
*/
export const SVG_SANITIZE_CONFIG = {
- USE_PROFILES: { svg: true, svgFilters: true },
- FORBID_TAGS: ['foreignObject', 'script']
+ FORBID_TAGS: ['foreignObject', 'script'],
+ USE_PROFILES: { svg: true, svgFilters: true }
};
/**
/* Title generation constants */
export const TITLE_GENERATION = {
- MIN_LENGTH: 3,
- FALLBACK: 'New Chat',
DEFAULT_PROMPT:
'Based on the following interaction, generate a short, concise title (maximum 6-8 words) that captures the main topic. Return ONLY the title text, nothing else. Do not use quotes.\n\nUser: {{USER}}\n\nAssistant: {{ASSISTANT}}\n\nTitle:',
+ FALLBACK: 'New Chat',
+ MIN_LENGTH: 3,
PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i,
QUOTE_PATTERN: /^["]|["]$/g
} as const;
+import { ROUTES } from './routes';
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
-import { ROUTES } from './routes';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
}
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
- { icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] },
- { icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] },
+ { icon: SquarePen, keys: ['shift', 'cmd', 'o'], route: ROUTES.NEW_CHAT, tooltip: 'New chat' },
+ { icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
+ activeRouteId: '/mcp-servers',
icon: McpLogo,
- tooltip: 'MCP Servers',
route: ROUTES.MCP_SERVERS,
- activeRouteId: '/mcp-servers'
+ tooltip: 'MCP Servers'
},
{
+ activeUrlIncludes: '#/settings',
icon: Settings,
- tooltip: 'Settings',
route: `${ROUTES.SETTINGS}/general`,
- activeUrlIncludes: '#/settings'
+ tooltip: 'Settings'
}
];
/** RFC 6570 URI template operators */
export const URI_TEMPLATE_OPERATORS = {
- /** Simple string expansion (default) */
- SIMPLE: '',
- /** Reserved expansion */
- RESERVED: '+',
+ /** Form-style query continuation */
+ FORM_CONTINUATION: '&',
+ /** Form-style query */
+ FORM_QUERY: '?',
/** Fragment expansion */
FRAGMENT: '#',
- /** Path segment expansion */
- PATH_SEGMENT: '/',
/** Label expansion */
LABEL: '.',
/** Path-style parameters */
PATH_PARAM: ';',
- /** Form-style query */
- FORM_QUERY: '?',
- /** Form-style query continuation */
- FORM_CONTINUATION: '&'
+ /** Path segment expansion */
+ PATH_SEGMENT: '/',
+ /** Reserved expansion */
+ RESERVED: '+',
+ /** Simple string expansion (default) */
+ SIMPLE: ''
} as const;
/** URI template separators used in expansion */
export const URI_TEMPLATE_SEPARATORS = {
/** Comma separator for list expansion */
COMMA: ',',
- /** Slash separator for path segments */
- SLASH: '/',
/** Period separator for label expansion */
PERIOD: '.',
- /** Semicolon separator for path parameters */
- SEMICOLON: ';',
+ /** Ampersand prefix for query continuation */
+ QUERY_CONTINUATION: '&',
/** Question mark prefix for query string */
QUERY_PREFIX: '?',
- /** Ampersand prefix for query continuation */
- QUERY_CONTINUATION: '&'
+ /** Semicolon separator for path parameters */
+ SEMICOLON: ';',
+ /** Slash separator for path segments */
+ SLASH: '/'
} as const;
/** Maximum number of leading slashes to strip during URI normalization */
const STD = ['com', 'net', 'org', 'gov', 'edu'] as const;
-
const STD_MIL = [...STD, 'mil'] as const;
-
const ccTLD_PREFIXES: Record<string, readonly string[]> = {
+ ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'],
// --- Standard 5 only ---
ar: STD,
+ au: [...STD_MIL, 'id', 'asn', 'csiro'],
bd: STD,
bg: STD,
- cn: STD_MIL,
- eg: STD,
- gr: STD,
- hk: STD,
- hr: STD,
- lk: STD,
- mx: STD_MIL,
- my: STD_MIL,
- ng: STD,
- ph: STD,
- pk: STD,
- pl: STD,
- ro: STD,
- ru: STD,
- sa: STD,
- si: STD,
- tr: STD,
- tw: STD,
- ua: STD,
- ve: STD,
-
- au: [...STD_MIL, 'id', 'asn', 'csiro'],
br: [
...STD_MIL,
'art',
'wiki',
'zlg'
],
+ cn: STD_MIL,
+ eg: STD,
+ gr: STD,
+ hk: STD,
+ hr: STD,
+ hu: ['co', 'net', 'org', 'gov', 'edu'],
id: [...STD_MIL, 'co', 'go', 'or', 'web', 'sch'],
+ il: ['co', 'net', 'org', 'gov', 'ac', 'muni'],
in: [...STD_MIL, 'co', 'gen', 'ind', 'firm', 'ernet', 'nic'],
+ jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
+ ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'],
kr: [...STD_MIL, 'co', 'go', 'or', 'ac', 're'],
+ lk: STD,
+ mx: STD_MIL,
+ my: STD_MIL,
+ ng: STD,
nz: [
...STD_MIL,
'co',
'iwi',
'parliament'
],
- sg: [...STD, 'per'],
- th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'],
- ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'],
- hu: ['co', 'net', 'org', 'gov', 'edu'],
- il: ['co', 'net', 'org', 'gov', 'ac', 'muni'],
- jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
- ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'],
+ ph: STD,
+ pk: STD,
+ pl: STD,
+ ro: STD,
rs: ['co', 'net', 'org', 'gov', 'edu'],
+ ru: STD,
+ sa: STD,
+ sg: [...STD, 'per'],
+
+ si: STD,
+ th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'],
+ tr: STD,
+ tw: STD,
+ ua: STD,
uk: ['co', 'org', 'net', 'ac', 'gov', 'mil', 'nhs', 'police', 'mod', 'ltd', 'plc', 'me', 'sch'],
+ ve: STD,
za: ['co', 'org', 'net', 'web', 'law', 'mil']
};
-
const WILDCARD_BASES: Record<string, readonly string[]> = {
br: ['nom', 'blog'],
jp: [
-import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants';
+import { getContext, setContext } from 'svelte';
export interface ChatActionsContext {
copy: (message: DatabaseMessage) => void;
-import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants';
+import { getContext, setContext } from 'svelte';
export interface ChatSettingsConfigContext {
readonly localConfig: SettingsConfigType;
-import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants';
import { MessageRole } from '$lib/enums';
+import { getContext, setContext } from 'svelte';
export interface MessageEditState {
readonly isEditing: boolean;
close: () => void
): UseAttachmentMenuReturn {
const modalityFlags = $derived(getFlags());
-
const callbacks = $derived.by(() => {
const cbs = getCallbacks();
const wrap = (fn?: () => void) => () => {
close();
fn?.();
};
+
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
- [AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick),
[AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
- [AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick)
+ [AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick),
+ [AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
};
});
function isItemEnabled(enabledWhen: string | undefined): boolean {
if (!enabledWhen || enabledWhen === 'always') return true;
+
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
function isItemVisible(visibleWhen: string | undefined): boolean {
if (!visibleWhen) return true;
+
return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
}
get callbacks() {
return callbacks;
},
+ getSystemMessageTooltip,
isItemEnabled,
- isItemVisible,
- getSystemMessageTooltip
+ isItemVisible
};
}
*/
setDisabled(disabled: boolean): void {
if (this._disabled === disabled) return;
+
this._disabled = disabled;
+
if (disabled) {
this._autoScrollEnabled = false;
this.stopInterval();
handleScroll(): void {
if (this._disabled || !this._container) return;
- const { scrollTop, scrollHeight, clientHeight } = this._container;
+ const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
+
this._container.scrollTop = this._container.scrollHeight;
}
*/
enable(): void {
if (this._disabled) return;
+
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
+
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
updateInterval(isStreaming: boolean): void {
if (this._disabled) {
this.stopInterval();
+
return;
}
this._mutationObserver = new MutationObserver(() => {
if (!this._autoScrollEnabled || this._rafPending) return;
+
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
+
if (this._autoScrollEnabled && this._container) {
this._container.scrollTop = this._container.scrollHeight;
}
});
this._mutationObserver.observe(this._container, {
+ characterData: true,
childList: true,
- subtree: true,
- characterData: true
+ subtree: true
});
}
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
+
this._rafPending = false;
}
}
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
+ type CommandDismissSnapshot,
findCommandToken,
findMentionToken,
+ type MentionDismissSnapshot,
takeCommandDismissSnapshot,
- takeMentionDismissSnapshot,
- type CommandDismissSnapshot,
- type MentionDismissSnapshot
+ takeMentionDismissSnapshot
} from '$lib/utils';
/** Dependencies injected as getters so the hook stays free of store circular imports. */
let mentionQuery = $state('');
let isWorkingDirectoryPickerOpen = $state(false);
let workingDirectoryQuery = $state('');
-
// Last dismissed `@`-mention token; while intact, the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
-
// Same dismissal contract for the `/`-command token.
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
// Fall back to the server home so the picker still finds matches
// before a cwd is set.
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
-
const availableCommands = $derived(
getChatCommands({
- showModelSelector: opts.getShowModelSelector(),
+ hasCwdTools: opts.hasCwdTools,
hasPrompts: opts.hasPrompts,
- hasCwdTools: opts.hasCwdTools
+ showModelSelector: opts.getShowModelSelector()
})
);
opts.setValue('');
isPromptPickerOpen = true;
promptSearchQuery = args.trim();
+
break;
case ChatFormCommandAction.CWD: {
// Keep `/cwd <args>` in the input so the search field and the
// token stay two-way bound; normalize partial tokens (`/cw foo`).
const trimmed = args.trim();
const newValue = `/cwd ${trimmed}`;
+
if (opts.getValue() !== newValue) {
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
}
+
workingDirectoryQuery = trimmed;
isWorkingDirectoryPickerOpen = true;
+
break;
}
case ChatFormCommandAction.MODEL:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
opts.openModelSelector();
+
break;
}
}
promptSearchQuery = '';
const token = findCommandToken(value);
+
if (!token) {
isCommandPickerOpen = false;
commandQuery = '';
+
return;
}
if (isWorkingDirectoryPickerOpen) {
isCommandPickerOpen = false;
commandQuery = '';
+
if (token.name === 'cwd') {
workingDirectoryQuery = token.args.trim();
} else {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
}
+
return;
}
if (isDismissedSticky) {
isCommandPickerOpen = false;
commandQuery = '';
+
return;
}
isCommandPickerOpen = false;
commandQuery = '';
}
+
return;
}
isCommandPickerOpen = false;
commandQuery = '';
+
if (commandDismissedSnapshot !== null) {
commandDismissedSnapshot = null;
}
+
if (isWorkingDirectoryPickerOpen) {
isWorkingDirectoryPickerOpen = false;
}
mentionQuery = token.query;
isPromptPickerOpen = false;
promptSearchQuery = '';
+
return;
}
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
+
return true;
}
function handleCommandSelect(command: ChatFormCommand) {
// Dispatch on the live token so typed args seed the target picker.
const token = findCommandToken(opts.getValue());
+
dispatchCommand(command, token?.args ?? '');
}
if (isCommandPickerOpen) {
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
}
+
isCommandPickerOpen = false;
commandQuery = '';
+
// Target picker manages its own focus: don't yank it back to the input.
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
opts.focusInput();
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
+
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
}
+
isMentionPickerOpen = false;
mentionQuery = '';
opts.focusInput();
// reverse direction is handled by handleInput.
$effect(() => {
if (!isWorkingDirectoryPickerOpen) return;
+
const value = opts.getValue();
const token = findCommandToken(value);
+
if (!token || token.name !== 'cwd') return;
+
const newValue = `/cwd ${workingDirectoryQuery}`;
+
if (newValue === value) return;
+
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
});
return {
- get isCommandPickerOpen() {
- return isCommandPickerOpen;
+ get availableCommands() {
+ return availableCommands;
},
- set isCommandPickerOpen(v: boolean) {
- isCommandPickerOpen = v;
+ closePromptPicker() {
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
},
get commandQuery() {
return commandQuery;
set commandQuery(v: string) {
commandQuery = v;
},
- get isPromptPickerOpen() {
- return isPromptPickerOpen;
- },
- set isPromptPickerOpen(v: boolean) {
- isPromptPickerOpen = v;
- },
- get promptSearchQuery() {
- return promptSearchQuery;
+ dispatchCommand,
+ handleCommandPickerClose,
+ handleCommandSelect,
+ handleInput,
+ // True when a picker consumed the event, so the form skips submit.
+ handleKeydown,
+ handleMentionPickerClose,
+ handlePromptPickerClose,
+ handleWorkingDirectoryClose,
+ handleWorkingDirectoryOpen,
+ get isCommandPickerOpen() {
+ return isCommandPickerOpen;
},
- set promptSearchQuery(v: string) {
- promptSearchQuery = v;
+ set isCommandPickerOpen(v: boolean) {
+ isCommandPickerOpen = v;
},
get isMentionPickerOpen() {
return isMentionPickerOpen;
set isMentionPickerOpen(v: boolean) {
isMentionPickerOpen = v;
},
- get mentionQuery() {
- return mentionQuery;
+ get isPromptPickerOpen() {
+ return isPromptPickerOpen;
},
- set mentionQuery(v: string) {
- mentionQuery = v;
+ set isPromptPickerOpen(v: boolean) {
+ isPromptPickerOpen = v;
},
get isWorkingDirectoryPickerOpen() {
return isWorkingDirectoryPickerOpen;
set isWorkingDirectoryPickerOpen(v: boolean) {
isWorkingDirectoryPickerOpen = v;
},
- get workingDirectoryQuery() {
- return workingDirectoryQuery;
- },
- set workingDirectoryQuery(v: string) {
- workingDirectoryQuery = v;
+ get mentionQuery() {
+ return mentionQuery;
},
- get availableCommands() {
- return availableCommands;
+ set mentionQuery(v: string) {
+ mentionQuery = v;
},
get mentionScopePath() {
return mentionScopePath;
},
- handleInput,
- // True when a picker consumed the event, so the form skips submit.
- handleKeydown,
- dispatchCommand,
- handleCommandSelect,
- handleCommandPickerClose,
- handleMentionPickerClose,
- handlePromptPickerClose,
- handleWorkingDirectoryOpen,
- handleWorkingDirectoryClose,
openPromptPicker() {
isPromptPickerOpen = true;
},
- closePromptPicker() {
- isPromptPickerOpen = false;
- promptSearchQuery = '';
+ get promptSearchQuery() {
+ return promptSearchQuery;
+ },
+ set promptSearchQuery(v: string) {
+ promptSearchQuery = v;
+ },
+ get workingDirectoryQuery() {
+ return workingDirectoryQuery;
+ },
+ set workingDirectoryQuery(v: string) {
+ workingDirectoryQuery = v;
}
};
}
* demand if they aren't cached yet.
*/
-import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
+import { modelOptions, modelsStore, selectedModelId } from '$lib/stores/models.svelte';
+import { isRouterMode } from '$lib/stores/server.svelte';
export function useChatScreenActiveModel() {
const isRouter = $derived(isRouterMode());
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
-
const activeModelId = $derived.by(() => {
const options = modelOptions();
}
const selectedId = selectedModelId();
+
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
+
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
+
if (model) return model.model;
}
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
+
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
const hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
+
return modelsStore.modelSupportsAudio(activeModelId);
}
+
return false;
});
-
const hasVideoModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
+
return modelsStore.modelSupportsVideo(activeModelId);
}
+
return false;
});
-
const hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
+
return modelsStore.modelSupportsVision(activeModelId);
}
+
return false;
});
return {
- get isRouter() {
- return isRouter;
+ get activeModelId() {
+ return activeModelId;
},
get conversationModel() {
return conversationModel;
},
- get activeModelId() {
- return activeModelId;
- },
get hasAudioModality() {
return hasAudioModality;
},
},
get hasVisionModality() {
return hasVisionModality;
+ },
+ get isRouter() {
+ return isRouter;
}
};
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
+
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
+
if (dragCounter === 0) {
isDragOver = false;
}
if (isEditing()) {
const handler = getAddFilesHandler();
+
if (handler) {
handler(files);
+
return;
}
}
}
return {
- get isDragOver() {
- return isDragOver;
- },
dragHandlers: {
dragenter: handleDragEnter,
dragleave: handleDragLeave,
dragover: handleDragOver,
drop: handleDrop
+ },
+ get isDragOver() {
+ return isDragOver;
}
};
}
* as reactive getters so validation tracks the model in real time.
*/
+import { filterFilesByModalities, isFileTypeSupported } from '$lib/utils';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
-import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
interface UseChatScreenFileUploadOptions {
capabilities: () => { hasVision: boolean; hasAudio: boolean; hasVideo: boolean };
let showFileErrorDialog = $state(false);
let fileErrorData = $state<FileErrorData>({
generallyUnsupported: [],
- modalityUnsupported: [],
modalityReasons: {},
+ modalityUnsupported: [],
supportedTypes: []
});
}
}
- const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
+ const { modalityReasons, supportedFiles, unsupportedFiles } = filterFilesByModalities(
generallySupported,
options.capabilities()
);
-
const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
if (allUnsupportedFiles.length > 0) {
const supportedTypes: string[] = ['text files', 'PDFs'];
const caps = options.capabilities();
+
if (caps.hasVision) supportedTypes.push('images');
+
if (caps.hasAudio) supportedTypes.push('audio files');
+
if (caps.hasVideo) supportedTypes.push('video files');
fileErrorData = {
generallyUnsupported,
- modalityUnsupported: unsupportedFiles,
modalityReasons,
+ modalityUnsupported: unsupportedFiles,
supportedTypes
};
showFileErrorDialog = true;
supportedFiles,
options.activeModelId() ?? undefined
);
+
uploadedFiles = [...uploadedFiles, ...processed];
}
}
}
return {
- get uploadedFiles() {
- return uploadedFiles;
- },
- set uploadedFiles(value) {
- uploadedFiles = value;
- },
+ fileErrorData,
+ handleFileRemove,
+ handleFileUpload,
get showFileErrorDialog() {
return showFileErrorDialog;
},
set showFileErrorDialog(value) {
showFileErrorDialog = value;
},
- fileErrorData,
- handleFileUpload,
- handleFileRemove
+ get uploadedFiles() {
+ return uploadedFiles;
+ },
+ set uploadedFiles(value) {
+ uploadedFiles = value;
+ }
};
}
* scroll handler seeing spurious events from layout shifts.
*/
-import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { AutoScrollController } from './use-auto-scroll.svelte';
+import { afterNavigate, beforeNavigate } from '$app/navigation';
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
// Ignore scroll events caused by navigation layout changes or by our own
// programmatic scrolls so they don't accidentally disable auto-scroll.
if (isNavigating || !event.isTrusted) return;
+
autoScroll.handleScroll();
}
* read / fresh / cache / output and cumulative token counts.
*/
+import { useProcessingState } from './use-processing-state.svelte';
+import {
+ type ColorLevel,
+ colorLevelFromPercent
+} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
+import { STATS_UNITS } from '$lib/constants';
+import { MessageRole } from '$lib/enums';
+import { chatStore } from '$lib/stores/chat.svelte';
+import { activeMessages } from '$lib/stores/conversations.svelte';
import {
- modelsStore,
modelOptions,
+ modelsStore,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
-import { chatStore } from '$lib/stores/chat.svelte';
-import { activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
-import { MessageRole } from '$lib/enums';
-import { STATS_UNITS } from '$lib/constants';
import type { ChatMessageTimings, DatabaseMessage } from '$lib/types';
-import { useProcessingState } from './use-processing-state.svelte';
-import {
- colorLevelFromPercent,
- type ColorLevel
-} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
interface LiveStats {
freshTokens: number;
function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
+
if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
}
+
return undefined;
}
if (!state || (state.status !== 'preparing' && state.status !== 'generating')) {
return null;
}
+
const promptTokens = state.promptTokens ?? 0;
const cacheTokens = state.cacheTokens ?? 0;
+
return {
- freshTokens: promptTokens,
- promptTokens: promptTokens + cacheTokens,
cacheTokens,
- outputTokens: state.outputTokensUsed ?? 0
+ freshTokens: promptTokens,
+ outputTokens: state.outputTokensUsed ?? 0,
+ promptTokens: promptTokens + cacheTokens
};
}
if (TRANSIENT_DETAILS_EXCLUDED_PREFIXES.some((prefix) => detail.startsWith(prefix))) {
return false;
}
+
return !detail.includes(STATS_UNITS.TOKENS_PER_SECOND);
});
}
export function useContextGauge(): UseContextGaugeReturn {
const processingState = useProcessingState();
-
// Resolve the model the gauge reports context for: explicit selection >
// last assistant model > single-model mode (mirrors useChatScreenActiveModel).
const activeModelId = $derived.by(() => {
}
const selectedId = selectedModelId();
+
if (selectedId) {
const model = modelOptions().find((m) => m.id === selectedId);
+
if (model) return model.model;
}
return chatStore.getConversationModel(activeMessages() as DatabaseMessage[]);
});
-
const isActiveModelLoaded = $derived(
activeModelId !== null && modelsStore.isModelLoaded(activeModelId)
);
-
const isActiveModelLoading = $derived(
activeModelId !== null && modelsStore.isModelOperationInProgress(activeModelId)
);
$effect(() => {
if (activeModelId && isActiveModelLoaded) {
const cached = modelsStore.getModelProps(activeModelId);
+
if (!cached) {
void modelsStore.fetchModelProps(activeModelId);
}
const contextTotal = $derived.by(() => {
void modelsStore.propsCacheVersion;
+
return activeModelId ? modelsStore.getModelContextSize(activeModelId) : null;
});
-
const liveStats = $derived(deriveLiveStats(processingState.processingState));
-
const currentRead = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+
let read = 0;
+
if (timings) {
read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
}
+
// live.promptTokens is already the combined reading (prompt + cache),
// so do not also add live.cacheTokens.
if (liveStats && liveStats.promptTokens > 0) {
read = Math.max(read, liveStats.promptTokens);
}
+
return read;
});
-
const currentFresh = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
const fresh = timings?.prompt_n ?? 0;
+
return Math.max(fresh, liveStats?.freshTokens ?? 0);
});
-
const currentCache = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
const cached = timings?.cache_n ?? 0;
+
if (liveStats && liveStats.promptTokens > 0) {
return Math.max(cached, liveStats.cacheTokens);
}
+
return cached;
});
-
const currentOutput = $derived.by(() => {
if (liveStats && liveStats.outputTokens > 0) return liveStats.outputTokens;
+
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+
return timings?.predicted_n ?? 0;
});
-
const kvTotal = $derived(currentRead + currentOutput);
const contextUsed = $derived(currentRead + currentOutput);
-
const cumulative = $derived.by(() => {
const messages = activeMessages() as DatabaseMessage[];
-
// Agentic sessions stamp the same agentic.llm totals onto every
// assistant message; cache_n is never per-turn so cache_total stays 0.
const agenticMessages = messages.filter(
const output = llm.predicted_n ?? 0;
const outputMs = llm.predicted_ms ?? 0;
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
+
return {
- read: llm.prompt_n ?? 0,
- output,
+ averageTokensPerSecond,
cacheTotal: 0,
- averageTokensPerSecond
+ output,
+ read: llm.prompt_n ?? 0
};
}
let output = 0;
let outputMs = 0;
let cacheTotal = 0;
+
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
+
read += m.timings.prompt_n ?? 0;
cacheTotal += m.timings.cache_n ?? 0;
output += m.timings.predicted_n ?? 0;
outputMs += m.timings.predicted_ms ?? 0;
}
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
- return { read, output, cacheTotal, averageTokensPerSecond };
- });
+ return { averageTokensPerSecond, cacheTotal, output, read };
+ });
const contextPercent = $derived.by(() => {
if (contextTotal === null || contextTotal <= 0) return null;
+
return Math.round((contextUsed / contextTotal) * 100);
});
-
const colorLevel = $derived(colorLevelFromPercent(contextPercent));
-
// Drop lines the surrounding Context / Output / speed rows already render.
const transientDetails = $derived(filterTransientDetails(processingState.getTechnicalDetails()));
-
const hasAnyUsage = $derived(
cumulative.read > 0 ||
cumulative.output > 0 ||
async function loadModel() {
if (!activeModelId || isActiveModelLoading) return;
+
try {
await modelsStore.loadModel(activeModelId);
} catch {
get activeModelId() {
return activeModelId;
},
- get isActiveModelLoaded() {
- return isActiveModelLoaded;
+ get averageTokensPerSecond() {
+ return cumulative.averageTokensPerSecond;
},
- get isActiveModelLoading() {
- return isActiveModelLoading;
+ get colorLevel() {
+ return colorLevel;
+ },
+ get contextPercent() {
+ return contextPercent;
},
get contextTotal() {
return contextTotal;
get contextUsed() {
return contextUsed;
},
- get currentRead() {
- return currentRead;
+ get cumulativeCacheTotal() {
+ return cumulative.cacheTotal;
},
- get currentFresh() {
- return currentFresh;
+ get cumulativeOutput() {
+ return cumulative.output;
+ },
+ get cumulativeRead() {
+ return cumulative.read;
},
get currentCache() {
return currentCache;
},
+ get currentFresh() {
+ return currentFresh;
+ },
get currentOutput() {
return currentOutput;
},
- get kvTotal() {
- return kvTotal;
- },
- get cumulativeRead() {
- return cumulative.read;
- },
- get cumulativeOutput() {
- return cumulative.output;
+ get currentRead() {
+ return currentRead;
},
- get cumulativeCacheTotal() {
- return cumulative.cacheTotal;
+ get hasAnyUsage() {
+ return hasAnyUsage;
},
- get averageTokensPerSecond() {
- return cumulative.averageTokensPerSecond;
+ get isActiveModelLoaded() {
+ return isActiveModelLoaded;
},
- get contextPercent() {
- return contextPercent;
+ get isActiveModelLoading() {
+ return isActiveModelLoading;
},
- get colorLevel() {
- return colorLevel;
+ get kvTotal() {
+ return kvTotal;
},
+ loadModel,
+ startMonitoring: () => processingState.startMonitoring(),
get transientDetails() {
return transientDetails;
- },
- get hasAnyUsage() {
- return hasAnyUsage;
- },
- loadModel,
- startMonitoring: () => processingState.startMonitoring()
+ }
};
}
const schedule = debounce((query: string) => {
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
+
void start(query);
}, opts.debounceMs);
async function start(query: string) {
cancel();
const fresh = new AbortController();
+
controller = fresh;
const mySeq = ++searchSeq;
+
isSearching = true;
try {
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
}
return {
+ cancel,
get isSearching() {
return isSearching;
},
- /** Bump the loading flag synchronously (e.g. before the debounce fires). */
- setLoading(value: boolean) {
- isSearching = value;
- },
run(query: string) {
schedule(query);
},
- cancel
+ /** Bump the loading flag synchronously (e.g. before the debounce fires). */
+ setLoading(value: boolean) {
+ isSearching = value;
+ }
};
}
-import { onMount } from 'svelte';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { draftMessagesStore } from '$lib/stores/draft-messages.svelte';
+import { onMount } from 'svelte';
interface UseDraftMessagesOptions {
getChatId: () => string | undefined;
beforeNavigate(() => {
const chatId = options.getChatId();
+
draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles());
});
if (navigation?.from != null) {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
+
options.setMessage(draft.message);
options.setFiles(draft.files);
}
function clearDraft() {
const chatId = options.getChatId();
+
draftMessagesStore.clearDraftMessage(chatId);
}
import { goto } from '$app/navigation';
-import { KeyboardKey } from '$lib/enums';
import { ROUTES } from '$lib/constants/routes';
+import { KeyboardKey } from '$lib/enums';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
const order = options.orderedIds();
const fromIdx = order.indexOf(fromId);
const toIdx = order.indexOf(toId);
+
if (fromIdx === -1 || toIdx === -1) return;
+
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
const shouldSelect = !selected.has(toId);
+
for (let i = lo; i <= hi; i++) {
const id = order[i];
+
if (shouldSelect) selected.add(id);
else selected.delete(id);
}
const attr = resolveAttributeName();
const selector = `[data-${attr}]`;
const key = datasetKey(attr);
+
let bestMatch: HTMLElement | null = null;
let bestCenterDistance = Infinity;
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
const rect = row.getBoundingClientRect();
+
if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) {
return row.dataset[key] ?? null;
}
+
if (x >= rect.left && x <= rect.right) {
const centerDistance = Math.abs(y - (rect.top + rect.height / 2));
+
if (centerDistance < bestCenterDistance) {
bestCenterDistance = centerDistance;
bestMatch = row;
}
}
}
+
return bestMatch ? (bestMatch.dataset[key] ?? null) : null;
}
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
const id = row.dataset[key];
+
if (!id || !visibleIds.has(id)) continue;
const rect = row.getBoundingClientRect();
if (event.shiftKey && dragAnchorId !== null) {
const target = findRowAtPoint(event.clientX, event.clientY);
+
if (target && target !== mousedownRowId) rangeSelect(dragAnchorId, target);
+
return;
}
if (!isMarqueeDragging) {
const dx = event.clientX - dragStartX;
const dy = event.clientY - dragStartY;
+
if (Math.hypot(dx, dy) < dragThresholdPx) return;
+
isMarqueeDragging = true;
dragMode = decideDragMode(mousedownRowId, options.selectedIds());
}
+
updateMarqueeRect(event.clientX, event.clientY);
}
if (isMarqueeDragging) {
suppressNextClick = true;
const target = findRowAtPoint(event.clientX, event.clientY);
+
if (target) dragAnchorId = target;
}
+
isMarqueeDragging = false;
mouseDownActive = false;
mousedownRowId = null;
$effect(() => {
if (!options.enabled()) {
reset();
+
return;
}
+
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
document.addEventListener('click', handleClickCapture, { capture: true });
+
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
function rowMouseDown(id: string, event: MouseEvent) {
if (!options.enabled()) return;
+
if (event.button !== 0) return;
+
event.preventDefault();
mouseDownActive = true;
mousedownRowId = id;
function rowClick(id: string, shiftKey: boolean) {
if (!options.enabled()) return;
+
const selected = options.selectedIds();
if (shiftKey) {
const anchor = dragAnchorId;
+
if (anchor !== null && anchor !== id) {
rangeSelect(anchor, id);
} else if (selected.has(id)) {
} else {
selected.add(id);
}
+
dragAnchorId = id;
+
return;
}
if (selected.has(id)) selected.delete(id);
else selected.add(id);
+
dragAnchorId = id;
}
}
return {
- rowMouseDown,
- rowClick,
- reset,
get dragAnchorId() {
return dragAnchorId;
- }
+ },
+ reset,
+ rowClick,
+ rowMouseDown
};
}
async function handleSaveEdit() {
const trimmed = editedContent.trim();
+
if (!trimmed && editedExtras.length === 0 && editedUploadedFiles.length === 0) return;
let finalExtras: DatabaseMessageExtra[] = $state.snapshot(editedExtras);
+
if (editedUploadedFiles.length > 0) {
const plainFiles = $state.snapshot(editedUploadedFiles);
const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || [];
+
finalExtras = [...finalExtras, ...newExtras];
}
}
setMessageEditContext({
- get isEditing() {
- return isEditing;
- },
+ cancel: handleCancelEdit,
get editedContent() {
return editedContent;
},
get editedUploadedFiles() {
return editedUploadedFiles;
},
+ get isEditing() {
+ return isEditing;
+ },
+ get messageRole() {
+ return MessageRole.USER;
+ },
get originalContent() {
return options.getContent();
},
get originalExtras() {
return options.getExtras();
},
- get showSaveOnlyOption() {
- return options.showSaveOnlyOption ?? false;
- },
- get showBranchAfterEditOption() {
- return false;
- },
- get shouldBranchAfterEdit() {
- return false;
- },
- get messageRole() {
- return MessageRole.USER;
- },
+ save: handleSaveEdit,
+ saveOnly: handleSaveEdit,
setContent: (c: string) => {
editedContent = c;
},
setUploadedFiles: (f: ChatUploadedFile[]) => {
editedUploadedFiles = f;
},
- save: handleSaveEdit,
- saveOnly: handleSaveEdit,
- cancel: handleCancelEdit,
+ get shouldBranchAfterEdit() {
+ return false;
+ },
+ get showBranchAfterEditOption() {
+ return false;
+ },
+ get showSaveOnlyOption() {
+ return options.showSaveOnlyOption ?? false;
+ },
startEdit: handleEdit
});
return {
- get isEditing() {
- return isEditing;
- },
+ handleCancelEdit,
handleEdit,
handleSaveEdit,
- handleCancelEdit
+ get isEditing() {
+ return isEditing;
+ }
};
}
-import { onMount } from 'svelte';
+import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
+import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import {
- modelsStore,
modelOptions,
modelsLoading,
+ modelsStore,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
-import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
-import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
+import { onMount } from 'svelte';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
const activeId = $derived(selectedModelId());
const isRouter = $derived(isRouterMode());
const serverModel = $derived(singleModelName());
-
const currentModel = $derived(opts.currentModel());
const onModelChange = $derived(opts.onModelChange?.());
-
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
+
const currentOption = options.find((option) => option.model === currentModel);
+
return currentOption ? currentOption.id === activeId : false;
});
-
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
+
return options.some((option) => option.model === currentModel);
});
let searchTerm = $state('');
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
+
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
const groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
+
if (!option) return;
let shouldCloseMenu = true;
if (displayModel) {
return {
+ capabilities: [],
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
- name: displayModel.split('/').pop() || displayModel,
- capabilities: []
+ name: displayModel.split('/').pop() || displayModel
};
}
if (currentModel) {
if (!isCurrentModelInCache) {
return {
+ capabilities: [],
id: 'not-in-cache',
model: currentModel,
- name: currentModel.split('/').pop() || currentModel,
- capabilities: []
+ name: currentModel.split('/').pop() || currentModel
};
}
}
return {
- get options() {
- return options;
+ get activeId() {
+ return activeId;
},
- get loading() {
- return loading;
+ get filteredOptions() {
+ return filteredOptions;
},
- get updating() {
- return updating;
+ getDisplayOption,
+
+ get groupedFilteredOptions() {
+ return groupedFilteredOptions;
},
- get activeId() {
- return activeId;
+ handleInfoClick,
+
+ handleOpenChange,
+
+ handleSelect,
+
+ get infoModelId() {
+ return infoModelId;
},
- get isRouter() {
- return isRouter;
+ get isCurrentModelInCache() {
+ return isCurrentModelInCache;
},
- get serverModel() {
- return serverModel;
+ isFavorite(model: string) {
+ return modelsStore.favoriteModelIds.has(model);
},
get isHighlightedCurrentModelActive() {
return isHighlightedCurrentModelActive;
},
- get isCurrentModelInCache() {
- return isCurrentModelInCache;
+ get isLoadingModel() {
+ return isLoadingModel;
},
- get filteredOptions() {
- return filteredOptions;
+ get isRouter() {
+ return isRouter;
},
- get groupedFilteredOptions() {
- return groupedFilteredOptions;
+ get loading() {
+ return loading;
},
- get isLoadingModel() {
- return isLoadingModel;
+ get options() {
+ return options;
},
get searchTerm() {
return searchTerm;
},
- get showModelDialog() {
- return showModelDialog;
- },
-
- get infoModelId() {
- return infoModelId;
+ get serverModel() {
+ return serverModel;
},
setSearchTerm(value: string) {
showModelDialog = value;
},
- handleInfoClick,
-
- handleSelect,
-
- handleOpenChange,
-
- isFavorite(model: string) {
- return modelsStore.favoriteModelIds.has(model);
+ get showModelDialog() {
+ return showModelDialog;
},
- getDisplayOption
+ get updating() {
+ return updating;
+ }
};
}
function resolve(from: number, dir: 1 | -1): number {
const n = opts.count();
+
if (n === 0) return -1;
+
if (opts.step) return opts.step(from, dir);
+
return wrapStep(from, dir, n);
}
function move(dir: 1 | -1) {
const next = resolve(hoveredIndex, dir);
+
if (next >= 0) {
hoveredIndex = next;
scrollTrigger++;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
opts.onClose();
+
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
+
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
+
return true;
}
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
event.preventDefault();
opts.onSelect(hoveredIndex);
+
return true;
}
+
// No selectable row - let the caller's Enter-to-submit run.
return false;
}
}
return {
+ bumpScroll,
+ handleKeydown,
get hoveredIndex() {
return hoveredIndex;
},
+ move,
+ reset,
get scrollTrigger() {
return scrollTrigger;
},
- reset,
- setHover,
- move,
- bumpScroll,
- handleKeydown
+ setHover
};
}
-import { activeProcessingState } from '$lib/stores/chat.svelte';
import { STATS_UNITS } from '$lib/constants';
-import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
+import { activeProcessingState } from '$lib/stores/chat.svelte';
+import type { ApiProcessingState, LiveGenerationStats, LiveProcessingStats } from '$lib/types';
export interface UseProcessingStateReturn {
readonly processingState: ApiProcessingState | null;
if (!isMonitoring) {
return lastKnownState;
}
+
// Read directly from the reactive state export
return activeProcessingState();
});
// Track last known processing stats for when promptProgress disappears
$effect(() => {
if (processingState?.promptProgress) {
- const { processed, total, time_ms, cache } = processingState.promptProgress;
+ const { cache, processed, time_ms, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
if (actualProcessed > 0 && time_ms > 0) {
const tokensPerSecond = actualProcessed / (time_ms / 1000);
+
lastKnownProcessingStats = {
- tokensProcessed: actualProcessed,
- totalTokens: actualTotal,
timeMs: time_ms,
- tokensPerSecond
+ tokensPerSecond,
+ tokensProcessed: actualProcessed,
+ totalTokens: actualTotal
};
}
}
done === 0 || elapsedSecs < 0.5
? undefined // can be the case for the 0% progress report
: elapsedSecs * (total / done - 1);
+
return progressETASecs;
}
function startMonitoring(): void {
if (isMonitoring) return;
+
isMonitoring = true;
}
if (processingState.progressPercent !== undefined) {
return `Processing (${processingState.progressPercent}%)`;
}
+
return 'Preparing response...';
case 'generating':
return '';
function getProcessingDetails(): string[] {
// Use current processing state or fall back to last known state
const stateToUse = processingState || lastKnownState;
+
if (!stateToUse) {
return [];
}
// Show prompt processing progress with ETA during preparation phase
if (stateToUse.promptProgress) {
- const { processed, total, time_ms, cache } = stateToUse.promptProgress;
+ const { cache, processed, time_ms, total } = stateToUse.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
+
details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`);
} else {
details.push(`Processing ${percent}%`);
*/
function getTechnicalDetails(): string[] {
const stateToUse = processingState || lastKnownState;
+
if (!stateToUse) {
return [];
}
function getPromptProgressText(): string | null {
if (!processingState?.promptProgress) return null;
- const { processed, total, cache } = processingState.promptProgress;
-
+ const { cache, processed, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
const percent = Math.round((actualProcessed / actualTotal) * 100);
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
+
return `Processing ${percent}% (ETA: ${etaSecs}s)`;
}
*/
function getLiveProcessingStats(): LiveProcessingStats | null {
if (processingState?.promptProgress) {
- const { processed, total, time_ms, cache } = processingState.promptProgress;
-
+ const { cache, processed, time_ms, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
const tokensPerSecond = actualProcessed / (time_ms / 1000);
return {
- tokensProcessed: actualProcessed,
- totalTokens: actualTotal,
timeMs: time_ms,
- tokensPerSecond
+ tokensPerSecond,
+ tokensProcessed: actualProcessed,
+ totalTokens: actualTotal
};
}
}
tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
return {
- tokensGenerated: tokensDecoded,
timeMs,
+ tokensGenerated: tokensDecoded,
tokensPerSecond: tokensPerSecond || 0
};
}
return {
- get processingState() {
- return processingState;
- },
+ getLiveGenerationStats,
+ getLiveProcessingStats,
getProcessingDetails,
- getTechnicalDetails,
getProcessingMessage,
getPromptProgressText,
- getLiveProcessingStats,
- getLiveGenerationStats,
+ getTechnicalDetails,
+ get processingState() {
+ return processingState;
+ },
shouldShowDetails,
startMonitoring,
stopMonitoring
import { browser } from '$app/environment';
-import { useRegisterSW } from 'virtual:pwa-register/svelte';
-import { versionStore } from '$lib/stores/version.svelte';
-import { BUILD_VERSION_LOCALSTORAGE_KEY } from '$lib/constants/storage';
import { SW_CONFIG } from '$lib/constants/pwa';
+import { BUILD_VERSION_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { versionStore } from '$lib/stores/version.svelte';
+import { useRegisterSW } from 'virtual:pwa-register/svelte';
/**
* Hook for PWA service worker registration, update polling, and build version mismatch detection.
if (swCheckInterval) {
clearInterval(swCheckInterval);
}
+
swCheckInterval = setInterval(async () => {
if (!r || r.installing || !navigator?.onLine) return;
'cache-control': SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE_CONTROL
}
});
+
if (resp?.status === 200) {
await r.update();
}
// This comparison detects server upgrades for non-PWA users.
$effect(() => {
if (!browser) return;
+
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
+
if (!currentVersion) return;
try {
const storedVersion = localStorage.getItem(BUILD_VERSION_LOCALSTORAGE_KEY);
+
needRefreshByStorage = !!storedVersion && storedVersion !== currentVersion;
localStorage.setItem(BUILD_VERSION_LOCALSTORAGE_KEY, currentVersion);
} catch {
get needRefresh() {
return pwaNeedRefresh;
},
- updateServiceWorker,
/** Version mismatch detected via localStorage (non-PWA users) */
get needRefreshByStorage() {
return needRefreshByStorage;
- }
+ },
+ updateServiceWorker
};
}
-import { ReasoningEffort } from '$lib/enums';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
-import type { ReasoningEffortLevel } from '$lib/types';
-import type { DatabaseMessage } from '$lib/types/database';
+import { ReasoningEffort } from '$lib/enums';
+import { chatStore } from '$lib/stores/chat.svelte';
+import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import {
- modelsStore,
checkModelSupportsThinking,
- supportsThinking,
+ loadedModelIds,
+ modelsStore,
propsCacheVersion,
- loadedModelIds
+ supportsThinking
} from '$lib/stores/models.svelte';
-import { chatStore } from '$lib/stores/chat.svelte';
-import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
+import type { ReasoningEffortLevel } from '$lib/types';
+import type { DatabaseMessage } from '$lib/types/database';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
-
// a router chat can carry reasoning from an earlier turn before the props
// cache is primed, so a model that already produced thinking still qualifies
const modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
+
if (!modelId) return false;
return conversationsStore.activeMessages.some(
(m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
);
});
-
const modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
+
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
return supportsThinking() || modelSupportsThinkingFromMessages;
});
-
const currentEffort = $derived(conversationsStore.getReasoningEffort());
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
return {
- get modelSupportsThinking() {
- return modelSupportsThinking;
- },
- get thinkingEnabled() {
- return thinkingEnabled;
+ get currentEffort() {
+ return currentEffort;
},
get isOff() {
return currentEffort === ReasoningEffort.OFF;
},
- get currentEffort() {
- return currentEffort;
+ isSelected(level: ReasoningEffortLevel): boolean {
+ return currentEffort === level.value;
},
get levels() {
return REASONING_EFFORT_LEVELS;
},
- isSelected(level: ReasoningEffortLevel): boolean {
- return currentEffort === level.value;
+ get modelSupportsThinking() {
+ return modelSupportsThinking;
+ },
+ select(level: ReasoningEffortLevel): void {
+ conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
+ },
+ get thinkingEnabled() {
+ return thinkingEnabled;
},
tokenLabel(level: ReasoningEffortLevel): string | null {
if (level.value === ReasoningEffort.DEFAULT) return 'Model default';
+
const tokens = REASONING_EFFORT_TOKENS[level.value];
+
if (tokens === undefined) return null;
+
return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`;
- },
- select(level: ReasoningEffortLevel): void {
- conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
}
};
}
$effect(() => {
const trigger = opts.getTrigger();
+
if (trigger === undefined) return;
// Skip the initial run on mount: the list opens with the first row
// positioned, which would scroll the whole page to the top.
if (lastTrigger === null) {
lastTrigger = trigger;
+
return;
}
if (trigger === lastTrigger) return;
+
lastTrigger = trigger;
untrack(() => {
const container = opts.getContainer();
const index = opts.getIndex();
+
if (!container || index < 0 || index >= opts.getCount()) return;
+
const row = container.querySelector(
`[data-${opts.dataIndex}-index="${index}"]`
) as HTMLElement | null;
+
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
});
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
-
const elementCenter = elementRect.left + elementRect.width / 2;
const containerCenter = containerRect.left + containerRect.width / 2;
const scrollOffset = elementCenter - containerCenter;
- scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
+ scrollContainer.scrollBy({ behavior: 'smooth', left: scrollOffset });
}
function scrollLeft() {
if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
+
+ scrollContainer.scrollBy({ behavior: 'smooth', left: -250 });
}
function scrollRight() {
if (!scrollContainer) return;
- scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
+
+ scrollContainer.scrollBy({ behavior: 'smooth', left: 250 });
}
function updateScrollButtons() {
if (!scrollContainer) return;
- const { scrollLeft: sl, scrollWidth, clientWidth } = scrollContainer;
+ const { clientWidth, scrollLeft: sl, scrollWidth } = scrollContainer;
+
canScrollLeft = sl > 0;
canScrollRight = sl < scrollWidth - clientWidth - 1;
}
set scrollContainer(el: HTMLDivElement | undefined) {
scrollContainer = el;
},
- scrollToCenter,
scrollLeft,
scrollRight,
+ scrollToCenter,
updateScrollButtons
};
}
-import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
-import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
+import { page } from '$app/state';
import { ROUTES } from '$lib/constants/routes';
+import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
export interface ChatSettings {
reset: () => void;
activePanel: 'chat' as 'chat' | 'settings' | 'mcp',
chatSettingsRef: undefined as ChatSettings | undefined
});
-
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
- beforeNavigate(({ to, from }) => {
+ beforeNavigate(({ from, to }) => {
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
settingsReferrer.url = window.location.hash || ROUTES.START;
}
});
return {
- get panel() {
- return subroute;
- },
-
get isSettingsRoute() {
return isSettingsRoute;
+ },
+
+ get panel() {
+ return subroute;
}
};
}
import { CLI_FLAGS } from '$lib/constants';
-import { SvelteSet } from 'svelte/reactivity';
import { ToolSource } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { ToolGroup } from '$lib/types';
+import { SvelteSet } from 'svelte/reactivity';
export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>;
*/
export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>();
-
const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived(
groups.filter(
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
const noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
+
if (toolsStore.toolGroups.length > 0) return null;
+
// Tools endpoint is unreachable (404) — server started without --tools
if (toolsStore.isToolsEndpointUnreachable) {
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
}
+
// Other errors — return null so UI shows "Failed to load tools"
if (toolsStore.error) return null;
+
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
});
function toggleGroupByKey(key: string): void {
// Find current group by key to get up-to-date tool references
const group = activeGroups.find((g) => g.key === key);
+
if (!group) return;
+
toolsStore.toggleGroup(group);
}
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
+
mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled));
}
return {
- expandedGroups,
- get groups() {
- return groups;
- },
get activeGroups() {
return activeGroups;
},
- get totalToolCount() {
- return totalToolCount;
+ expandedGroups,
+ getEnabledToolCount,
+ getFavicon,
+ get groups() {
+ return groups;
},
+ handleOpen,
+ isGroupChecked,
+ isGroupDisabled,
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
- isGroupChecked,
- getEnabledToolCount,
- getFavicon,
- isGroupDisabled,
- toggleGroupExpanded,
toggleGroupByKey,
- handleOpen
+ toggleGroupExpanded,
+ get totalToolCount() {
+ return totalToolCount;
+ }
};
}
-import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
-import { formatAttachmentText } from '$lib/utils/formatters';
-import { isAbortError } from '$lib/utils/abort';
-import { streamIdentity } from '$lib/utils/stream-identity';
+import { settingsStore } from '../stores/settings.svelte';
+import { capImageDataURLSize } from '../utils/cap-img-size';
import {
- ATTACHMENT_LABEL_PDF_FILE,
+ API_CHAT,
+ API_SLOTS,
+ API_STREAM,
ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE,
+ ATTACHMENT_LABEL_PDF_FILE,
+ CONTROL_ACTION,
LEGACY_AGENTIC_REGEX,
REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
- API_CHAT,
- API_SLOTS,
- CONTROL_ACTION,
- SSE_LINE_SEPARATOR,
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
- STREAM_VISIBILITY_KICK_MS,
+ SSE_LINE_SEPARATOR,
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
- API_STREAM
+ STREAM_VISIBILITY_KICK_MS
} from '$lib/constants';
import {
AttachmentType,
ReasoningFormat,
StreamConnectionState
} from '$lib/enums';
-import type {
- ApiChatMessageContentPart,
- ApiChatMessageData,
- ApiChatCompletionToolCall,
- ApiStreamSession
-} from '$lib/types/api';
+import { modelsStore } from '$lib/stores/models.svelte';
import type {
AudioInputFormat,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource
} from '$lib/types';
-import { modelsStore } from '$lib/stores/models.svelte';
-import { settingsStore } from '../stores/settings.svelte';
-import { capImageDataURLSize } from '../utils/cap-img-size';
+import type {
+ ApiChatCompletionToolCall,
+ ApiChatMessageContentPart,
+ ApiChatMessageData,
+ ApiStreamSession
+} from '$lib/types/api';
+import { isAbortError } from '$lib/utils/abort';
+import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
+import { formatAttachmentText } from '$lib/utils/formatters';
+import { streamIdentity } from '$lib/utils/stream-identity';
function getAudioInputFormat(mimeType: string): AudioInputFormat {
const normalizedMimeType = mimeType.trim().toLowerCase();
signal?: AbortSignal
): Promise<string> {
let titleResponse = '';
+
try {
await ChatService.sendMessage(
[message],
{
- model: model || undefined,
- stream: true,
custom: { chat_template_kwargs: { enable_thinking: false } },
+ model: model || undefined,
onChunk: (chunk: string) => {
titleResponse += chunk;
- }
+ },
+ stream: true
},
undefined,
signal
} catch {
return '';
}
+
return titleResponse;
}
signal?: AbortSignal
): Promise<string | void> {
const {
- stream,
+ backend_sampling,
+ continueFinalMessage,
+ custom,
+ // Config options
+ disableReasoningParsing,
+ dry_allowed_length,
+ dry_base,
+ dry_multiplier,
+ dry_penalty_last_n,
+ dynatemp_exponent,
+ // Sampling parameters
+ dynatemp_range,
+ enableThinking,
+ excludeReasoningFromContext,
+ frequency_penalty,
+ max_tokens,
+ min_p,
onChunk,
onComplete,
- onError,
+ onCompletionId,
onConnectionState,
- onReasoningChunk,
- onToolCallChunk,
+ onError,
onModel,
- onCompletionId,
+ onReasoningChunk,
onTimings,
- // Tools for function calling
- tools,
- // Generation parameters
- temperature,
- max_tokens,
- // Sampling parameters
- dynatemp_range,
- dynatemp_exponent,
- top_k,
- top_p,
- min_p,
- xtc_probability,
- xtc_threshold,
- typ_p,
+ onToolCallChunk,
+ presence_penalty,
+ reasoningEffort,
// Penalty parameters
repeat_last_n,
repeat_penalty,
- presence_penalty,
- frequency_penalty,
- dry_multiplier,
- dry_base,
- dry_allowed_length,
- dry_penalty_last_n,
// Other parameters
samplers,
- backend_sampling,
- custom,
+ stream,
+ // Generation parameters
+ temperature,
timings_per_token,
- // Config options
- disableReasoningParsing,
- excludeReasoningFromContext,
- enableThinking,
- reasoningEffort,
- continueFinalMessage
+ // Tools for function calling
+ tools,
+ top_k,
+ top_p,
+ typ_p,
+ xtc_probability,
+ xtc_threshold
} = options;
-
const normalizedMessages: ApiChatMessageData[] = (
await Promise.all(
messages.map((msg) => {
return true;
});
+
// If only text remains and it's a single part, simplify to string
if (
msg.content.length === 1 &&
const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
const mapped: ApiChatCompletionRequest['messages'][0] = {
- role: msg.role,
content: msg.content,
- tool_calls: msg.tool_calls,
- tool_call_id: msg.tool_call_id
+ role: msg.role,
+ tool_call_id: msg.tool_call_id,
+ tool_calls: msg.tool_calls
};
+
// Include reasoning_content from the dedicated field
if (!excludeReasoningFromContext && msg.reasoning_content) {
mapped.reasoning_content = msg.reasoning_content;
}
+
return mapped;
}),
- stream,
return_progress: stream ? true : undefined,
sse_ping_interval: stream ? 1 : undefined,
+ stream,
tools: tools && tools.length > 0 ? tools : undefined
};
}
if (temperature !== undefined) requestBody.temperature = temperature;
+
if (max_tokens !== undefined) {
// Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
}
if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
+
if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
+
if (top_k !== undefined) requestBody.top_k = top_k;
+
if (top_p !== undefined) requestBody.top_p = top_p;
+
if (min_p !== undefined) requestBody.min_p = min_p;
+
if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
+
if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
+
if (typ_p !== undefined) requestBody.typ_p = typ_p;
if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
+
if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
+
if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
+
if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
+
if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
+
if (dry_base !== undefined) requestBody.dry_base = dry_base;
+
if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
+
if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
if (samplers !== undefined) {
if (custom) {
try {
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
+
Object.assign(requestBody, customParams);
} catch (error) {
console.warn('Failed to parse custom parameters:', error);
try {
const headers: Record<string, string> = { ...getJsonHeaders() };
+
// tag streaming requests with the conversation id, this single header is the opt in for the
// server side replay buffer and powers discoverActiveStream on tab reopen. with an explicit
// model the ::model suffix keeps the per model session distinct
}
const response = await fetch(API_CHAT.COMPLETIONS, {
- method: 'POST',
- headers,
body: JSON.stringify(requestBody),
+ headers,
+ method: 'POST',
signal
});
if (conversationId) {
ChatService.clearStreamState(conversationId);
}
+
const error = await ChatService.parseErrorResponse(response);
if (onError) {
} catch (error) {
if (isAbortError(error)) {
console.log('Chat completion request was aborted');
+
return;
}
try {
const url = model ? `${API_SLOTS.LIST}?model=${encodeURIComponent(model)}` : API_SLOTS.LIST;
const res = await fetch(url, { signal });
+
if (!res.ok) return true;
const slots: { is_processing: boolean }[] = await res.json();
+
return slots.every((s) => !s.is_processing);
} catch {
return true;
console.error(
'stopReasoning: no completion id for the active message, cannot target the running completion'
);
+
return false;
}
const body: Record<string, unknown> = {
- id: completionId,
- action: CONTROL_ACTION.END_REASONING
+ action: CONTROL_ACTION.END_REASONING,
+ id: completionId
};
+
if (model) body.model = model;
try {
const res = await fetch(API_CHAT.CONTROL, {
- method: 'POST',
+ body: JSON.stringify(body),
headers: getJsonHeaders(),
- body: JSON.stringify(body)
+ method: 'POST'
});
-
const data = await res.json().catch(() => null);
+
if (!res.ok || data?.success !== true) {
console.error('stopReasoning: control request failed', {
- status: res.status,
completionId,
- response: data
+ response: data,
+ status: res.status
});
+
return false;
}
+
return true;
} catch (error) {
console.error('stopReasoning: control request threw', { completionId, error });
+
return false;
}
}
*/
static async cancelServerStream(conversationId: string, model?: string | null): Promise<void> {
if (!conversationId) return;
+
try {
const id = streamIdentity(conversationId, model);
+
await fetch(`${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}`, {
- method: 'DELETE',
- headers: getAuthHeaders()
+ headers: getAuthHeaders(),
+ method: 'DELETE'
});
} catch (e) {
console.warn('cancelServerStream failed:', e);
if (!Array.isArray(sessions) || sessions.length === 0) {
return null;
}
+
const running = sessions.filter((s) => !s.is_done);
+
if (running.length === 0) {
return null;
}
+
return running.reduce((best, cur) => (cur.started_at > best.started_at ? cur : best));
}
model?: string | null
): void {
if (!conversationId) return;
+
try {
const state: ResumableStreamState = {
bytesReceived,
- updatedAt: Date.now(),
- model: model ?? null
+ model: model ?? null,
+ updatedAt: Date.now()
};
+
localStorage.setItem(streamStorageKey(conversationId), JSON.stringify(state));
} catch {
// localStorage may be full or disabled, silently ignore
static getStreamState(conversationId: string): ResumableStreamState | null {
if (!conversationId) return null;
+
try {
const raw = localStorage.getItem(streamStorageKey(conversationId));
+
if (!raw) return null;
+
const parsed = JSON.parse(raw) as ResumableStreamState;
+
if (!parsed || typeof parsed.bytesReceived !== 'number') return null;
+
return parsed;
} catch {
return null;
static clearStreamState(conversationId: string): void {
if (!conversationId) return;
+
try {
localStorage.removeItem(streamStorageKey(conversationId));
} catch {
fallbackModel: string | null
): string {
const model = state && state.model !== undefined ? state.model : fallbackModel;
+
return streamIdentity(conversationId, model);
}
// so issue the GET and abort it right after the status line. 0 on network error
static async probeResumeStatus(streamId: string): Promise<number> {
if (!streamId) return 0;
+
const ac = new AbortController();
+
try {
const resp = await fetch(
`${API_STREAM.BASE}?conv_id=${encodeURIComponent(streamId)}&from=0`,
signal: ac.signal
}
);
+
ac.abort();
+
return resp.status;
} catch {
return 0;
model?: string | null
): Promise<Response | null> {
if (!conversationId) return null;
+
const state = ChatService.getStreamState(conversationId);
const from = state?.bytesReceived ?? 0;
const id = streamIdentity(conversationId, model);
const url = `${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}&from=${from}`;
- return await fetch(url, { method: 'GET', signal, headers: getAuthHeaders() });
+
+ return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
}
static async preEncode(
return true;
});
-
const requestBody: Record<string, unknown> = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => {
const mapped: Record<string, unknown> = {
- role: msg.role,
content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content,
- tool_calls: msg.tool_calls,
- tool_call_id: msg.tool_call_id
+ role: msg.role,
+ tool_call_id: msg.tool_call_id,
+ tool_calls: msg.tool_calls
};
if (!excludeReasoning && msg.reasoning_content) {
return mapped;
}),
- stream: false,
- n_predict: 0
+ n_predict: 0,
+ stream: false
};
if (model) {
try {
await fetch(API_CHAT.COMPLETIONS, {
- method: 'POST',
- headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
+ headers: getJsonHeaders(),
+ method: 'POST',
signal
});
} catch (error) {
// if a resume returns 200 but yields nothing, we abandon
// since the session has a bounded size, the total number of retries is bounded by construction
let madeProgress = true;
+
const encoder = new TextEncoder();
+
if (conversationId) {
ChatService.saveStreamState(conversationId, 0, streamModel);
}
+
onConnectionState?.(StreamConnectionState.STREAMING);
let decoder = new TextDecoder();
toolCallIndexOffset = aggregatedToolCalls.length;
hasOpenToolCallBatch = false;
};
-
const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
if (!toolCalls || toolCalls.length === 0) {
return;
onToolCallChunk?.(serializedToolCalls);
}
};
-
const onVisibilityChange = () => {
if (typeof document === 'undefined') return;
+
if (document.visibilityState !== 'visible') return;
+
if (streamFinished) return;
+
if (!conversationId) return;
+
// the bytes have been quiet for too long, the OS likely killed the socket
// kicking the reader unblocks reader.read with done=true so the outer loop can resume
if (Date.now() - lastByteAt > STREAM_VISIBILITY_KICK_MS) {
reader!.cancel().catch(() => {});
}
};
+
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
}
try {
let chunk = '';
+
// outer loop drives the resume cycle, swaps reader on premature end of stream
while (true) {
while (true) {
let done: boolean;
let value: Uint8Array | undefined;
+
try {
const r = await reader.read();
+
done = r.done;
value = r.value;
} catch (readErr) {
if (isAbortError(readErr)) {
throw readErr;
}
+
console.warn('reader.read() rejected, treating as premature end:', readErr);
done = true;
value = undefined;
}
+
if (done) break;
if (abortSignal?.aborted) break;
if (value && value.byteLength > 0) {
segmentBytesRead += value.byteLength;
lastByteAt = Date.now();
+
if (!madeProgress) {
madeProgress = true;
onConnectionState?.(StreamConnectionState.STREAMING);
chunk += decoder.decode(value, { stream: true });
const lines = chunk.split(SSE_LINE_SEPARATOR);
+
chunk = lines.pop() || '';
// the persisted offset must point right after the last fully parsed line,
// the trailing `chunk` is partial bytes still waiting for a newline
if (conversationId) {
const tailBytes = encoder.encode(chunk).byteLength;
+
bytesParsed = segmentStartOffset + segmentBytesRead - tailBytes;
ChatService.saveStreamState(conversationId, bytesParsed, streamModel);
}
if (line.startsWith(SSE_DATA_PREFIX)) {
const data = line.slice(SSE_DATA_PREFIX.length).trim();
+
if (data === SSE_DONE_MARKER) {
streamFinished = true;
const toolCalls = choice?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;
-
const chunkModel = ChatService.extractModelName(parsed);
+
if (chunkModel && !modelEmitted) {
modelEmitted = true;
onModel?.(chunkModel);
if (content) {
finalizeOpenToolCallBatch();
aggregatedContent += content;
+
if (!abortSignal?.aborted) {
onChunk?.(content);
}
if (reasoningContent) {
finalizeOpenToolCallBatch();
fullReasoningContent += reasoningContent;
+
if (!abortSignal?.aborted) {
onReasoningChunk?.(reasoningContent);
}
}
if (abortSignal?.aborted) break;
+
if (streamFinished) break;
}
// inner reader done, decide whether to try a resume
if (abortSignal?.aborted) break;
+
if (streamFinished) break;
+
if (!conversationId) break;
if (!madeProgress) {
onConnectionState?.(StreamConnectionState.LOST);
onError?.(new Error('Stream resume produced no new bytes, giving up'));
+
break;
}
abortSignal,
streamModel
).catch(() => null);
+
// an abort landing during the resume request is intentional, not a lost connection
if (abortSignal?.aborted) break;
+
if (!resumeResp || resumeResp.status !== 200) {
onConnectionState?.(StreamConnectionState.LOST);
onError?.(new Error('Stream connection lost and could not be resumed'));
+
break;
}
+
const newReader = resumeResp.body?.getReader();
+
if (!newReader) break;
try {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
+
try {
reader.releaseLock();
} catch {
}
const data: ApiChatCompletionResponse = JSON.parse(responseText);
-
const responseModel = ChatService.extractModelName(data);
+
if (responseModel) {
onModel?.(responseModel);
}
if (mergedToolCalls.length > 0) {
serializedToolCalls = JSON.stringify(mergedToolCalls);
+
if (serializedToolCalls) {
onToolCallChunk?.(serializedToolCalls);
}
// Handle tool result messages (role: 'tool')
if (message.role === MessageRole.TOOL && message.toolCallId) {
return {
- role: MessageRole.TOOL,
content: message.content,
+ role: MessageRole.TOOL,
tool_call_id: message.toolCallId
};
}
// Parse tool calls for assistant messages
let toolCalls: ApiChatCompletionToolCall[] | undefined;
+
if (message.toolCalls) {
try {
toolCalls = JSON.parse(message.toolCalls);
if (!message.extra || message.extra.length === 0) {
const result: ApiChatMessageData = {
- role: message.role as MessageRole,
- content: message.content
+ content: message.content,
+ role: message.role as MessageRole
};
if (message.reasoningContent) {
}
const contentParts: ApiChatMessageContentPart[] = [];
-
const textFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
extra.type === AttachmentType.TEXT
for (const textFile of textFiles) {
contentParts.push({
- type: ContentPartType.TEXT,
- text: formatAttachmentText('File', textFile.name, textFile.content)
+ text: formatAttachmentText('File', textFile.name, textFile.content),
+ type: ContentPartType.TEXT
});
}
for (const legacyContextFile of legacyContextFiles) {
contentParts.push({
- type: ContentPartType.TEXT,
- text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
+ text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content),
+ type: ContentPartType.TEXT
});
}
for (const image of imageFiles) {
const maxImageResolution = settingsStore.getConfig(SETTINGS_KEYS.MAX_IMAGE_RESOLUTION);
-
// Caps the resolution and bakes the jpeg exif orientation in one pass,
// untouched images pass through as is
const base64Url = await capImageDataURLSize(image.base64Url, maxImageResolution);
contentParts.push({
- type: ContentPartType.IMAGE_URL,
- image_url: { url: base64Url }
+ image_url: { url: base64Url },
+ type: ContentPartType.IMAGE_URL
});
}
for (const audio of audioFiles) {
contentParts.push({
- type: ContentPartType.INPUT_AUDIO,
input_audio: {
data: audio.base64Data,
format: getAudioInputFormat(audio.mimeType)
- }
+ },
+ type: ContentPartType.INPUT_AUDIO
});
}
if (message.content) {
contentParts.push({
- type: ContentPartType.TEXT,
- text: message.content
+ text: message.content,
+ type: ContentPartType.TEXT
});
}
for (const video of videoFiles) {
contentParts.push({
- type: ContentPartType.INPUT_VIDEO,
input_video: {
data: video.base64Data,
format: video.mimeType.includes('mp4')
: video.mimeType.includes('ogg')
? 'ogg'
: 'auto'
- }
+ },
+ type: ContentPartType.INPUT_VIDEO
});
}
if (pdfFile.processedAsImages && pdfFile.images) {
for (let i = 0; i < pdfFile.images.length; i++) {
contentParts.push({
- type: ContentPartType.IMAGE_URL,
- image_url: { url: pdfFile.images[i] }
+ image_url: { url: pdfFile.images[i] },
+ type: ContentPartType.IMAGE_URL
});
}
} else {
contentParts.push({
- type: ContentPartType.TEXT,
- text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
+ text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content),
+ type: ContentPartType.TEXT
});
}
}
for (const mcpPrompt of mcpPrompts) {
contentParts.push({
- type: ContentPartType.TEXT,
text: formatAttachmentText(
ATTACHMENT_LABEL_MCP_PROMPT,
mcpPrompt.name,
mcpPrompt.content,
mcpPrompt.serverName
- )
+ ),
+ type: ContentPartType.TEXT
});
}
for (const mcpResource of mcpResources) {
contentParts.push({
- type: ContentPartType.TEXT,
text: formatAttachmentText(
ATTACHMENT_LABEL_MCP_RESOURCE,
mcpResource.name,
mcpResource.content,
mcpResource.serverName
- )
+ ),
+ type: ContentPartType.TEXT
});
}
const result: ApiChatMessageData = {
- role: message.role as MessageRole,
- content: contentParts
+ content: contentParts,
+ role: message.role as MessageRole
};
+
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
+
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
+
return result;
}
if (part.type === ContentPartType.TEXT && part.text) {
return { ...part, text: stripFromString(part.text) };
}
+
return part;
});
}
try {
const errorText = await response.text();
const errorData: ApiErrorResponse = JSON.parse(errorText);
-
const message = errorData.error?.message || 'Unknown server error';
const error = new Error(message) as Error & {
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
};
+
error.name = response.status === 400 ? 'ServerError' : 'HttpError';
if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
error.contextInfo = {
- n_prompt_tokens: errorData.error.n_prompt_tokens,
- n_ctx: errorData.error.n_ctx
+ n_ctx: errorData.error.n_ctx,
+ n_prompt_tokens: errorData.error.n_prompt_tokens
};
}
) as Error & {
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
};
+
fallback.name = 'HttpError';
return fallback;
? (value as Record<string, unknown>)
: undefined;
};
-
const getTrimmedString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
-
const root = asRecord(data);
+
if (!root) return undefined;
// 1) root (some implementations provide `model` at the top level)
const rootModel = getTrimmedString(root.model);
+
if (rootModel) {
return rootModel;
}
// 2) streaming choice (delta) or final response (message)
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
+
if (!firstChoice) {
return undefined;
}
// priority: delta.model (first chunk) else message.model (final response)
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
+
if (deltaModel) {
return deltaModel;
}
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
+
if (messageModel) {
return messageModel;
}
-import Dexie, { type EntityTable } from 'dexie';
-import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
-import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants';
+import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import type { ExportedConversation } from '$lib/types/database';
+import { filterByLeafNodeId, findDescendantMessages, uuid } from '$lib/utils';
+import Dexie, { type EntityTable } from 'dexie';
class LlamaUiDatabase extends Dexie {
[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
): Promise<DatabaseConversation> {
const conversation: DatabaseConversation = {
+ currNode: '',
id: uuid(),
- name,
lastModified: Date.now(),
- currNode: '',
+ name,
...fields
};
await db[IDXDB_TABLES.conversations].add(conversation);
+
return conversation;
}
// Handle null parent (root message case)
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
+
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
const newMessage: DatabaseMessage = {
...message,
+ children: [],
id: uuid(),
parent: parentId,
- toolCalls: message.toolCalls ?? '',
- children: []
+ toolCalls: message.toolCalls ?? ''
};
await db[IDXDB_TABLES.messages].add(newMessage);
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
+
if (parentMessage) {
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, newMessage.id]
*/
static async createRootMessage(convId: string): Promise<string> {
const rootMessage: DatabaseMessage = {
- id: uuid(),
- convId,
- type: 'root',
- timestamp: Date.now(),
- role: MessageRole.SYSTEM,
+ children: [],
content: '',
+ convId,
+ id: uuid(),
parent: null,
+ role: MessageRole.SYSTEM,
+ timestamp: Date.now(),
toolCalls: '',
- children: []
+ type: 'root'
};
await db[IDXDB_TABLES.messages].add(rootMessage);
+
return rootMessage.id;
}
parentId: string
): Promise<DatabaseMessage> {
const trimmedPrompt = systemPrompt.trim();
+
if (!trimmedPrompt) {
throw new Error('Cannot create system message with empty content');
}
return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
+
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
const systemMessage: DatabaseMessage = {
- id: uuid(),
- convId,
- type: MessageRole.SYSTEM,
- timestamp: Date.now(),
- role: MessageRole.SYSTEM,
+ children: [],
content: trimmedPrompt,
+ convId,
+ id: uuid(),
parent: parentId,
- children: []
+ role: MessageRole.SYSTEM,
+ timestamp: Date.now(),
+ type: MessageRole.SYSTEM
};
await db[IDXDB_TABLES.messages].add(systemMessage);
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
+
if (!conv) return;
let newParent = conv.forkedFromConversationId;
+
const visited = new Set<string>([parentId]);
+
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
+
break;
}
+
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
+
if (!next) {
newParent = undefined;
+
break;
}
+
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
-
const updates: DatabaseConversation[] = [];
+
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
+
updates.push({ ...child, forkedFromConversationId: newParent });
}
+
if (updates.length === 0) return;
+
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
+
if (cleanIds.length === 0) return;
+
const idSet = new Set(cleanIds);
await db.transaction(
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
+
let frontier = [...cleanIds];
+
const requested = new Set<string>(frontier);
+
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
+
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
+
if (!conv || !conv.id) continue;
+
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
+
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
static async deleteMessage(messageId: string): Promise<void> {
await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
const message = await db[IDXDB_TABLES.messages].get(messageId);
+
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
+
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
.where('convId')
.equals(conversationId)
.toArray();
-
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
-
// Get the message to delete for parent cleanup
const message = await db[IDXDB_TABLES.messages].get(messageId);
+
if (message && message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
+
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
+
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
-
const messagesByConv = new Map<string, DatabaseMessage[]>();
+
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
+
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
+
if (!conv) continue;
+
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
+
result.set(conv.id, { conv, messages });
}
+
return result;
}
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
+
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
+
const newPinnedState = !conversation.pinned;
+
await this.updateConversation(id, { pinned: newPinnedState });
+
return newPinnedState;
}
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
+
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
+
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
+
if (!conv) continue;
+
const newPinned = !conv.pinned;
+
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
+
if (updates.length === 0) return;
+
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
+
return result;
}
async () => {
for (const item of data) {
const { conv, messages } = item;
-
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
+
if (existing) {
skipped.push(conv);
+
continue;
}
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId);
+
if (!sourceConv) {
throw new Error(`Source conversation ${sourceConvId} not found`);
}
.where('convId')
.equals(sourceConvId)
.toArray();
-
const pathMessages = filterByLeafNodeId(
allMessages,
atMessageId,
true
) as DatabaseMessage[];
+
if (pathMessages.length === 0) {
throw new Error(`Could not resolve message path to ${atMessageId}`);
}
return {
...msg,
- id: newId,
- convId: newConvId,
- parent: newParent,
children: newChildren,
- extra: options.includeAttachments ? msg.extra : undefined
+ convId: newConvId,
+ extra: options.includeAttachments ? msg.extra : undefined,
+ id: newId,
+ parent: newParent
};
});
-
const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
const newConv: DatabaseConversation = {
- id: newConvId,
- name: options.name,
- lastModified: Date.now(),
currNode: lastClonedMessage.id,
+ cwd: sourceConv.cwd,
forkedFromConversationId: sourceConvId,
+ id: newConvId,
+ lastModified: Date.now(),
mcpServerOverrides: sourceConv.mcpServerOverrides
? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
- serverId: o.serverId,
- enabled: o.enabled
+ enabled: o.enabled,
+ serverId: o.serverId
}))
: undefined,
- cwd: sourceConv.cwd
+ name: options.name
};
await db[IDXDB_TABLES.conversations].add(newConv);
import { Client } from '@modelcontextprotocol/sdk/client';
+import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import {
StreamableHTTPClientTransport,
StreamableHTTPError
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
-import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
- Tool,
- Prompt,
GetPromptResult,
- ListChangedHandlers
+ ListChangedHandlers,
+ Prompt,
+ Tool
} from '@modelcontextprotocol/sdk/types.js';
-import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
- DEFAULT_MCP_CONFIG,
+ CORS_PROXY_ENDPOINT,
+ CORS_PROXY_HEADER_PREFIX,
DEFAULT_CLIENT_VERSION,
DEFAULT_IMAGE_MIME_TYPE,
- CORS_PROXY_HEADER_PREFIX,
- MCP_PARTIAL_REDACT_HEADERS,
- CORS_PROXY_ENDPOINT
+ DEFAULT_MCP_CONFIG,
+ MCP_PARTIAL_REDACT_HEADERS
} from '$lib/constants';
import {
MCPConnectionPhase,
- MCPLogLevel,
- MCPTransportType,
MCPContentType,
- MCPRefType
+ MCPLogLevel,
+ MCPRefType,
+ MCPTransportType
} from '$lib/enums';
import type {
- MCPServerConfig,
- MCPResourceIcon,
- ToolCallParams,
- ToolExecutionResult,
- Implementation,
ClientCapabilities,
+ Implementation,
MCPConnection,
- MCPPhaseCallback,
MCPConnectionLog,
- MCPServerInfo,
+ MCPPhaseCallback,
+ MCPReadResourceResult,
MCPResource,
- MCPResourceTemplate,
MCPResourceContent,
- MCPReadResourceResult
+ MCPResourceIcon,
+ MCPResourceTemplate,
+ MCPServerConfig,
+ MCPServerInfo,
+ ToolCallParams,
+ ToolExecutionResult
} from '$lib/types';
import {
- buildProxiedUrl,
buildProxiedHeaders,
- getAuthHeaders,
- sanitizeHeaders,
- throwIfAborted,
- isAbortError,
+ buildProxiedUrl,
createBase64DataUrl,
- getRequestUrl,
- getRequestMethod,
+ extractJsonRpcMethods,
+ formatDiagnosticErrorMessage,
+ getAuthHeaders,
getRequestBody,
+ getRequestMethod,
+ getRequestUrl,
+ isAbortError,
+ type RequestBodySummary,
+ sanitizeHeaders,
summarizeRequestBody,
- formatDiagnosticErrorMessage,
- extractJsonRpcMethods,
- type RequestBodySummary
+ throwIfAborted
} from '$lib/utils';
interface ToolResultContentItem {
details?: unknown
): MCPConnectionLog {
return {
- timestamp: new Date(),
- phase,
- message,
+ details,
level,
- details
+ message,
+ phase,
+ timestamp: new Date()
};
}
): DiagnosticRequestDetails {
const body = getRequestBody(input, init);
const details: DiagnosticRequestDetails = {
- url: getRequestUrl(input),
- method: getRequestMethod(input, init, baseInit).toUpperCase(),
+ body: summarizeRequestBody(body),
credentials: init?.credentials ?? baseInit.credentials,
- mode: init?.mode ?? baseInit.mode,
headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS),
- body: summarizeRequestBody(body)
+ method: getRequestMethod(input, init, baseInit).toUpperCase(),
+ mode: init?.mode ?? baseInit.mode,
+ url: getRequestUrl(input)
};
const jsonRpcMethods = extractJsonRpcMethods(body);
useProxy && !key.toLowerCase().startsWith(CORS_PROXY_HEADER_PREFIX)
? `${CORS_PROXY_HEADER_PREFIX}${key}`
: key;
+
requestHeaders.set(proxiedKey, value);
}
}
private static summarizeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
- name: error.name,
- message: error.message,
cause:
error.cause instanceof Error
- ? { name: error.cause.name, message: error.cause.message }
+ ? { message: error.cause.message, name: error.cause.name }
: error.cause,
+ message: error.message,
+ name: error.name,
stack: error.stack?.split('\n').slice(0, 6).join('\n')
};
}
}
return {
+ isSecureContext: window.isSecureContext,
location: window.location.href,
origin: window.location.origin,
protocol: window.location.protocol,
- isSecureContext: window.isSecureContext,
+ sameOrigin: window.location.origin === targetUrl.origin,
targetOrigin: targetUrl.origin,
targetProtocol: targetUrl.protocol,
- sameOrigin: window.location.origin === targetUrl.origin,
useProxy
};
}
disable: () => void;
} {
let enabled = true;
+
const logIfEnabled = (log: MCPConnectionLog) => {
if (enabled) {
onLog?.(log);
};
return {
+ disable: () => {
+ enabled = false;
+ },
fetch: async (input, init) => {
if (useProxy && typeof window !== 'undefined') {
let requestUrlStr = '';
+
if (typeof input === 'string') {
requestUrlStr = input;
} else if (input instanceof URL) {
if (requestUrlStr) {
const parsedRequestUrl = new URL(requestUrlStr, window.location.origin);
+
if (
parsedRequestUrl.origin === window.location.origin &&
!parsedRequestUrl.pathname.includes(CORS_PROXY_ENDPOINT)
`HTTP ${method} ${url}`,
MCPLogLevel.INFO,
{
- serverName,
- request
+ request,
+ serverName
}
)
);
MCPLogLevel.INFO,
{
response: {
- url,
+ durationMs: 0,
+ isFake: true,
status: response.status,
statusText: response.statusText,
- durationMs: 0,
- isFake: true
+ url
}
}
)
response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN,
{
response: {
- url,
+ durationMs,
+ headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
status: response.status,
statusText: response.statusText,
- headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
- durationMs
+ url
}
}
)
`HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`,
MCPLogLevel.ERROR,
{
- serverName,
- request,
- error: this.summarizeError(error),
browser: this.getBrowserContext(targetUrl, useProxy),
+ durationMs,
+ error: this.summarizeError(error),
hints: this.getConnectionHints(targetUrl, config, error),
- durationMs
+ request,
+ serverName
}
)
);
throw error;
}
- },
- disable: () => {
- enabled = false;
}
};
}
}
return {
+ stopPhaseLogging: () => {},
transport: new WebSocketClientTransport(url),
- type: MCPTransportType.WEBSOCKET,
- stopPhaseLogging: () => {}
+ type: MCPTransportType.WEBSOCKET
};
}
if (config.transport === MCPTransportType.SSE) {
const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
- const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch(
+ const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
serverName,
config,
requestInit,
}
return {
+ stopPhaseLogging,
transport: new SSEClientTransport(url, {
- requestInit,
+ eventSourceInit: { fetch: diagnosticFetch },
fetch: diagnosticFetch,
- eventSourceInit: { fetch: diagnosticFetch }
+ requestInit
}),
- type: MCPTransportType.SSE,
- stopPhaseLogging
+ type: MCPTransportType.SSE
};
}
const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
- const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch(
+ const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
serverName,
config,
requestInit,
}
return {
+ stopPhaseLogging,
transport: new StreamableHTTPClientTransport(url, {
- requestInit,
- fetch: diagnosticFetch
+ fetch: diagnosticFetch,
+ requestInit
}),
- type: MCPTransportType.STREAMABLE_HTTP,
- stopPhaseLogging
+ type: MCPTransportType.STREAMABLE_HTTP
};
} catch (httpError) {
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
try {
return {
+ stopPhaseLogging,
transport: new SSEClientTransport(url, {
- requestInit,
+ eventSourceInit: { fetch: diagnosticFetch },
fetch: diagnosticFetch,
- eventSourceInit: { fetch: diagnosticFetch }
+ requestInit
}),
- type: MCPTransportType.SSE,
- stopPhaseLogging
+ type: MCPTransportType.SSE
};
} catch (sseError) {
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
}
return {
- name: impl.name,
- version: impl.version,
- title: impl.title,
description: impl.description,
- websiteUrl: impl.websiteUrl,
icons: impl.icons?.map((icon: MCPResourceIcon) => ({
- src: icon.src,
mimeType: icon.mimeType,
sizes: icon.sizes,
+ src: icon.src,
theme: icon.theme
- }))
+ })),
+ name: impl.name,
+ title: impl.title,
+ version: impl.version,
+ websiteUrl: impl.websiteUrl
};
}
}
const {
+ stopPhaseLogging,
transport,
- type: transportType,
- stopPhaseLogging
+ type: transportType
} = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log));
// Setup WebSocket reconnection handler
listChanged: listChangedHandlers
}
);
-
const runtimeErrorHandler = (error: Error) => {
// the SDK reports any post initialize error here, including the abort we trigger
// ourselves on the next health check cycle, on tab unload, or on server teardown.
if (isAbortError(error)) {
return;
}
+
const msg = error?.message ?? '';
+
if (
/SSE stream disconnected:.*AbortError/i.test(msg) ||
/AbortError: .*aborted/i.test(msg) ||
) {
return;
}
+
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
};
try {
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
+
const handshakeDeadline = new Promise<never>((_, reject) => {
handshakeTimer = setTimeout(() => {
void transport.close().catch(() => {});
}`,
MCPLogLevel.ERROR,
{
- error: this.summarizeError(error),
+ browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
config: {
- serverName,
configuredUrl: serverConfig.url,
+ credentials: serverConfig.credentials,
effectiveUrl: url.href,
- transportType,
- useProxy: serverConfig.useProxy ?? false,
headers: sanitizeHeaders(
serverConfig.headers,
Object.keys(serverConfig.headers ?? {}),
MCP_PARTIAL_REDACT_HEADERS
),
- credentials: serverConfig.credentials
+ serverName,
+ transportType,
+ useProxy: serverConfig.useProxy ?? false
},
- browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
+ error: this.summarizeError(error),
hints: this.getConnectionHints(url, serverConfig, error)
}
)
}
),
{
- serverInfo,
- serverCapabilities,
clientCapabilities: effectiveCapabilities,
- instructions
+ instructions,
+ serverCapabilities,
+ serverInfo
}
);
const tools = await this.listTools({
client,
- transport,
- tools: [],
- serverName,
- transportType,
connectionTimeMs: 0,
requestTimeoutMs:
- serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000
+ serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
+ serverName,
+ tools: [],
+ transport,
+ transportType
});
-
const connectionTimeMs = Math.round(performance.now() - startTime);
// Phase: Connected
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
)
);
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
return {
client,
- transport,
- tools,
- serverName,
- transportType,
- serverInfo,
- serverCapabilities,
clientCapabilities: effectiveCapabilities,
- protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
- instructions,
connectionTimeMs,
+ instructions,
+ protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
requestTimeoutMs:
- serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000
+ serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
+ serverCapabilities,
+ serverInfo,
+ serverName,
+ tools,
+ transport,
+ transportType
};
}
// by not setting onerror, but since we use it for protocol logging,
// we must clear it before disconnect.
connection.client.onerror = undefined;
+
if (connection.transport.onclose) {
connection.transport.onclose = undefined;
}
args?: Record<string, string>
): Promise<GetPromptResult> {
try {
- return await connection.client.getPrompt({ name, arguments: args });
+ return await connection.client.getPrompt({ arguments: args, name });
} catch (error) {
console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
try {
const result = await connection.client.callTool(
- { name: params.name, arguments: params.arguments },
+ { arguments: params.arguments, name: params.name },
undefined,
{ signal, timeout: connection.requestTimeoutMs }
);
*/
private static formatToolResult(result: ToolCallResult): string {
const content = result.content;
+
if (!Array.isArray(content)) return '';
return content
const resource = content.resource;
if (resource.text) return resource.text;
+
if (resource.blob) return resource.blob;
return JSON.stringify(resource);
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
try {
const result = await connection.client.complete({
- ref,
- argument
+ argument,
+ ref
});
return result.completion;
const result = await connection.client.listResources(cursor ? { cursor } : undefined);
return {
- resources: (result.resources ?? []) as MCPResource[],
- nextCursor: result.nextCursor
+ nextCursor: result.nextCursor,
+ resources: (result.resources ?? []) as MCPResource[]
};
} catch (error) {
if (this.isSessionExpiredError(error)) {
*/
static async listAllResources(connection: MCPConnection): Promise<MCPResource[]> {
const allResources: MCPResource[] = [];
+
let cursor: string | undefined;
do {
const result = await this.listResources(connection, cursor);
+
allResources.push(...result.resources);
cursor = result.nextCursor;
} while (cursor);
const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined);
return {
- resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[],
- nextCursor: result.nextCursor
+ nextCursor: result.nextCursor,
+ resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[]
};
} catch (error) {
if (this.isSessionExpiredError(error)) {
*/
static async listAllResourceTemplates(connection: MCPConnection): Promise<MCPResourceTemplate[]> {
const allTemplates: MCPResourceTemplate[] = [];
+
let cursor: string | undefined;
do {
const result = await this.listResourceTemplates(connection, cursor);
+
allTemplates.push(...result.resourceTemplates);
cursor = result.nextCursor;
} while (cursor);
const result = await connection.client.readResource({ uri });
return {
- contents: (result.contents ?? []) as MCPResourceContent[],
- _meta: result._meta
+ _meta: result._meta,
+ contents: (result.contents ?? []) as MCPResourceContent[]
};
} catch (error) {
console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error);
* 4. Theme key: Copy standalone `theme` → config object (both preserved)
*/
-import Dexie from 'dexie';
import {
- STORAGE_APP_NAME,
- STORAGE_APP_NAME_DEPRECATED,
- DB_APP_NAME_DEPRECATED,
CONFIG_LOCALSTORAGE_KEY,
- IDXDB_TABLES,
+ DB_APP_NAME_DEPRECATED,
IDXDB_STORES,
- NEW_TO_DEPRECATED_MAP
+ IDXDB_TABLES,
+ NEW_TO_DEPRECATED_MAP,
+ STORAGE_APP_NAME,
+ STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants';
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { MessageRole } from '$lib/enums';
+import Dexie from 'dexie';
// Types
function getMigrationState(): MigrationState {
try {
const raw = localStorage.getItem(MIGRATION_STATE_KEY);
+
if (!raw) return { completed: [], failed: [], lastRun: '' };
+
const parsed = JSON.parse(raw);
+
if (parsed.version !== MIGRATION_STATE_VERSION) {
return { completed: [], failed: [], lastRun: '' };
}
+
return {
completed: parsed.completed ?? [],
failed: parsed.failed ?? [],
function isMigrationCompleted(id: string): boolean {
const state = getMigrationState();
+
return state.completed.includes(id);
}
function markMigrationCompleted(id: string): void {
const state = getMigrationState();
+
if (!state.completed.includes(id)) {
state.completed.push(id);
}
+
state.failed = state.failed.filter((f) => f !== id);
saveMigrationState(state);
}
function markMigrationFailed(id: string): void {
const state = getMigrationState();
+
if (!state.failed.includes(id)) {
state.failed.push(id);
}
+
saveMigrationState(state);
}
// Migration 1: LocalStorage Key Prefix (Non-Destructive)
const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1';
-
const localStorageMigration: Migration = {
- id: LOCALSTORAGE_MIGRATION_ID,
description: 'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)',
+ id: LOCALSTORAGE_MIGRATION_ID,
async run(): Promise<void> {
// Non-destructive: copy to new key, but KEEP the old key
for (const [newKey, deprecatedKey] of Object.entries(NEW_TO_DEPRECATED_MAP)) {
// Only migrate if new key doesn't already exist
const newValue = localStorage.getItem(newKey);
+
if (newValue !== null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
+
continue;
}
const oldValue = localStorage.getItem(deprecatedKey);
+
if (oldValue !== null) {
localStorage.setItem(newKey, oldValue);
+
// Keep old key for downgrade compatibility - DO NOT DELETE
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
// Migration 2: IndexedDB Database Name (Non-Destructive)
+// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const IDXDB_MIGRATION_ID = 'idxdb-database-v1';
-
const idxdbMigration: Migration = {
- id: IDXDB_MIGRATION_ID,
description: 'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)',
+ id: IDXDB_MIGRATION_ID,
async run(): Promise<void> {
const oldDbNames = await Dexie.getDatabaseNames();
+
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: no old database found, skipping');
+
return;
}
// Check if new database already has data
const newDb = new Dexie(STORAGE_APP_NAME);
+
newDb.version(1).stores(IDXDB_STORES);
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
+
if (existingConvs > 0) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: new database already has data, skipping');
+
return;
}
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
+
oldDb.version(1).stores(IDXDB_STORES);
const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray();
if (conversations.length > 0) {
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
}
+
if (messages.length > 0) {
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
}
// Migration 3: Legacy Message Format
+// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2';
interface ParsedTurn {
}
currentTurn.toolCalls.push({
- name: match[1],
args: match[2],
+ name: match[1],
result: match[3].replace(/^\n+|\n+$/g, '')
});
const cleanRemaining = remainingText
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
.trim();
+
if (cleanRemaining) {
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
}
let cleanContent = content;
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
+
let match;
+
while ((match = re.exec(content)) !== null) {
reasoning += match[1];
}
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
- return { reasoning, cleanContent };
+ return { cleanContent, reasoning };
}
function hasLegacyMarkers(content: string): boolean {
async function getDatabaseService() {
if (!DatabaseService) {
const module = await import('./database.service');
+
DatabaseService = module.DatabaseService;
}
+
return DatabaseService;
}
const legacyMessageMigration: Migration = {
- id: LEGACY_MESSAGE_MIGRATION_ID,
description: 'Migrate legacy marker-based messages to structured format',
+ id: LEGACY_MESSAGE_MIGRATION_ID,
async run(): Promise<void> {
const db = await getDatabaseService();
const conversations = await db.getAllConversations();
+
let migratedCount = 0;
for (const conv of conversations) {
for (const message of allMessages) {
if (message.role !== MessageRole.ASSISTANT) {
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
- const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
+ const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
+
await db.updateMessage(message.id, {
content: cleanContent.trim(),
reasoningContent: reasoning || undefined
});
migratedCount++;
}
+
continue;
}
if (!hasLegacyMarkers(message.content ?? '')) continue;
- const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
+ const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
const turns = parseLegacyToolCalls(cleanContent);
let existingToolCalls: Array<{
id: string;
function?: { name: string; arguments: string };
}> = [];
+
if (message.toolCalls) {
try {
existingToolCalls = JSON.parse(message.toolCalls);
}
const firstTurn = turns[0];
+
if (!firstTurn) continue;
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
const existing =
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
+
return {
+ function: { arguments: tc.args, name: tc.name },
id: existing?.id || `legacy_tool_${i}`,
- type: 'function' as const,
- function: { name: tc.name, arguments: tc.args }
+ type: 'function' as const
};
});
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
const tc = firstTurn.toolCalls[i];
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
-
const toolMsg = await db.createMessageBranch(
{
+ children: [],
+ content: tc.result,
convId: conv.id,
- type: 'text',
role: MessageRole.TOOL,
- content: tc.result,
- toolCallId,
timestamp: message.timestamp + i + 1,
+ toolCallId,
toolCalls: '',
- children: []
+ type: 'text'
},
currentParentId
);
+
currentParentId = toolMsg.id;
}
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
const turn = turns[turnIdx];
-
const turnToolCalls = turn.toolCalls.map((tc, i) => {
const idx = toolCallIdCounter + i;
const existing = existingToolCalls[idx];
+
return {
+ function: { arguments: tc.args, name: tc.name },
id: existing?.id || `legacy_tool_${idx}`,
- type: 'function' as const,
- function: { name: tc.name, arguments: tc.args }
+ type: 'function' as const
};
});
+
toolCallIdCounter += turn.toolCalls.length;
const assistantMsg = await db.createMessageBranch(
{
+ children: [],
+ content: turn.textBefore,
convId: conv.id,
- type: 'text',
+ model: message.model,
role: MessageRole.ASSISTANT,
- content: turn.textBefore,
timestamp: message.timestamp + turnIdx * 100,
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
- children: [],
- model: message.model
+ type: 'text'
},
currentParentId
);
+
currentParentId = assistantMsg.id;
for (let i = 0; i < turn.toolCalls.length; i++) {
const tc = turn.toolCalls[i];
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
-
const toolMsg = await db.createMessageBranch(
{
+ children: [],
+ content: tc.result,
convId: conv.id,
- type: 'text',
role: MessageRole.TOOL,
- content: tc.result,
- toolCallId,
timestamp: message.timestamp + turnIdx * 100 + i + 1,
+ toolCallId,
toolCalls: '',
- children: []
+ type: 'text'
},
currentParentId
);
+
currentParentId = toolMsg.id;
}
}
if (message.children.length > 0 && currentParentId !== message.id) {
for (const childId of message.children) {
const child = allMessages.find((m) => m.id === childId);
+
if (!child) continue;
+
if (child.role !== MessageRole.TOOL) {
await db.updateMessage(childId, { parent: currentParentId });
}
// Migration 4: Theme Key (Non-Destructive)
+// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const THEME_MIGRATION_ID = 'theme-key-v1';
-
const themeMigration: Migration = {
- id: THEME_MIGRATION_ID,
description: 'Copy standalone theme key to config object (non-destructive)',
+ id: THEME_MIGRATION_ID,
async run(): Promise<void> {
const legacyTheme = localStorage.getItem('theme');
+
if (legacyTheme === null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: no legacy theme key found, skipping');
+
return;
}
if (SETTINGS_KEYS.THEME in config) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: config already has theme, skipping');
+
return;
}
// Migration Registry & Runner
+// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1';
-
const customJsonKeyMigration: Migration = {
- id: CUSTOM_JSON_MIGRATION_ID,
description: 'Copy legacy custom config key to customJson (non-destructive)',
+ id: CUSTOM_JSON_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
if (configRaw === null) return;
const config = JSON.parse(configRaw);
if (!('custom' in config)) return;
+
if (SETTINGS_KEYS.CUSTOM_JSON in config) return;
config[SETTINGS_KEYS.CUSTOM_JSON] = config.custom;
console.log(`[Migration] Custom JSON: copied custom to customJson (preserved old key)`);
}
};
-
const MCP_DEFAULT_ENABLED_MIGRATION_ID = 'mcp-default-enabled-to-config-v1';
-
const LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
const DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`;
-
const mcpDefaultEnabledMigration: Migration = {
- id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
description:
'Copy mcpDefaultEnabled localStorage key into settings config (preserves legacy keys)',
+ id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
async run(): Promise<void> {
const raw =
if (raw === null) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default enabled: no legacy key found, skipping');
+
return;
}
if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default enabled: config already has overrides, skipping');
+
return;
}
try {
const parsed = JSON.parse(raw);
+
if (!Array.isArray(parsed)) return;
+
const valid = parsed.every(
(o) =>
typeof o === 'object' &&
typeof (o as Record<string, unknown>).serverId === 'string' &&
typeof (o as Record<string, unknown>).enabled === 'boolean'
);
+
if (!valid) return;
} catch {
return;
console.log('[Migration] MCP default enabled: moved legacy key into config');
}
};
-
const CONFIG_TYPES_MIGRATION_ID = 'config-type-normalization-v1';
-
const configTypesMigration: Migration = {
- id: CONFIG_TYPES_MIGRATION_ID,
description: 'Coerce legacy string-encoded booleans in persisted config to real booleans',
+ id: CONFIG_TYPES_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
if (configRaw === null) return;
const config = JSON.parse(configRaw);
+
let changed = false;
// Pre-schema configs persisted booleans as "true"/"false" strings; the strict server
console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
}
};
-
const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
-
/**
* Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list,
* JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`.
* standalone overrides are already inside the config.
*/
const mcpDefaultOverridesMergeMigration: Migration = {
- id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
description:
'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)',
+ id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
async run(): Promise<void> {
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
if (configRaw === null) return;
const config = JSON.parse(configRaw);
if (typeof raw !== 'string' || raw.length === 0) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] MCP default overrides merge: nothing to merge');
+
return;
}
let overrides: { serverId: string; enabled: boolean }[];
+
try {
const parsed = JSON.parse(raw);
+
if (!Array.isArray(parsed)) return;
+
overrides = parsed.filter(
(o) =>
typeof o === 'object' &&
}
const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS];
+
let servers: { id: string; enabled?: boolean }[];
+
try {
servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : [];
} catch {
if (!Array.isArray(servers)) servers = [];
let serversChanged = false;
+
const knownIds = new Set(servers.map((s) => s.id));
+
for (const override of overrides) {
if (!knownIds.has(override.serverId)) continue;
+
const index = servers.findIndex((s) => s.id === override.serverId);
if (index >= 0 && servers[index].enabled !== override.enabled) {
);
}
};
-
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
},
/**
- * Check if a specific migration has been completed
+ * Get current migration state
*/
- isCompleted(id: string): boolean {
- return isMigrationCompleted(id);
+ getState(): MigrationState {
+ return getMigrationState();
},
/**
- * Get current migration state
+ * Check if a specific migration has been completed
*/
- getState(): MigrationState {
- return getMigrationState();
+ isCompleted(id: string): boolean {
+ return isMigrationCompleted(id);
},
/**
*/
resetState(): void {
localStorage.removeItem(MIGRATION_STATE_KEY);
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] State reset - all migrations will run again');
},
*/
async runAllMigrations(): Promise<void> {
const state = getMigrationState();
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Starting migration run, state:', state);
if (isMigrationCompleted(migration.id)) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: already completed, skipping`);
+
continue;
}
try {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: running...`);
+
await migration.run();
markMigrationCompleted(migration.id);
+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: completed successfully`);
} catch (error) {
-import { ServerModelStatus } from '$lib/enums';
-import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
-import type { ParsedModelId } from '$lib/types/models';
import {
- MODEL_QUANTIZATION_SEGMENT_RE,
- MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
- MODEL_PARAMS_RE,
+ API_MODELS,
MODEL_ACTIVATED_PARAMS_RE,
- MODEL_IGNORED_SEGMENTS,
- MODEL_WEIGHT_EXTENSION_RE,
+ MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
MODEL_ID_NOT_FOUND,
MODEL_ID_ORG_SEPARATOR,
- MODEL_ID_SEGMENT_SEPARATOR,
MODEL_ID_QUANTIZATION_SEPARATOR,
- API_MODELS
+ MODEL_ID_SEGMENT_SEPARATOR,
+ MODEL_IGNORED_SEGMENTS,
+ MODEL_PARAMS_RE,
+ MODEL_QUANTIZATION_SEGMENT_RE,
+ MODEL_WEIGHT_EXTENSION_RE
} from '$lib/constants';
+import { ServerModelStatus } from '$lib/enums';
+import type { ParsedModelId } from '$lib/types/models';
+import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
export class ModelsService {
/**
*/
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
const payload: { model: string; extra_args?: string[] } = { model: modelId };
+
if (extraArgs && extraArgs.length > 0) {
payload.extra_args = extraArgs;
}
*/
static parseModelId(modelId: string): ParsedModelId {
const result: ParsedModelId = {
- raw: modelId,
- orgName: null,
+ activatedParams: null,
modelName: null,
+ orgName: null,
params: null,
- activatedParams: null,
quantization: null,
+ raw: modelId,
tags: []
};
-
// strip directory path and weight extension so a bare `-m /path/file.gguf`
// parses like a clean repo id; the HF `org/model` form is preserved
const source = normalizeModelName(modelId).replace(MODEL_WEIGHT_EXTENSION_RE, '');
-
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
const colonIdx = source.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
+
let modelPath: string;
if (colonIdx !== MODEL_ID_NOT_FOUND) {
// 2. Extract org name (e.g. `org/model` -> org = "org")
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
+
let modelStr: string;
if (slashIdx !== MODEL_ID_NOT_FOUND) {
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
const absIdx = paramsIdx + 1 + relIdx;
+
if (absIdx === activatedParamsIdx) return false;
return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase());
-import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync.service';
+import { describe, expect, it } from 'vitest';
describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => {
it('should fix JavaScript floating-point precision issues', () => {
// Test the specific values from the screenshot
const mockServerParams = {
- top_p: 0.949999988079071,
min_p: 0.009999999776482582,
+ samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature'],
temperature: 0.800000011920929,
top_k: 40,
- samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
+ top_p: 0.949999988079071
};
-
const result = ParameterSyncService.extractServerDefaults({
...mockServerParams,
- // Add other required fields to match the API type
- n_predict: 512,
- seed: -1,
- dynatemp_range: 0.0,
- dynatemp_exponent: 1.0,
- xtc_probability: 0.0,
- xtc_threshold: 0.1,
- typ_p: 1.0,
- repeat_last_n: 64,
- repeat_penalty: 1.0,
- presence_penalty: 0.0,
- frequency_penalty: 0.0,
- dry_multiplier: 0.0,
- dry_base: 1.75,
+ chat_format: '',
dry_allowed_length: 2,
+ dry_base: 1.75,
+ dry_multiplier: 0.0,
dry_penalty_last_n: 64,
+ dry_sequence_breakers: [],
+ dynatemp_exponent: 1.0,
+ dynatemp_range: 0.0,
+ frequency_penalty: 0.0,
+ generation_prompt: '',
+ grammar: '',
+ grammar_lazy: false,
+ grammar_triggers: [],
+ ignore_eos: false,
+ logit_bias: [],
+ lora: [],
+ max_tokens: -1,
+ min_keep: 0,
mirostat: 0,
- mirostat_tau: 5.0,
mirostat_eta: 0.1,
- stop: [],
- max_tokens: -1,
- n_keep: 0,
+ mirostat_tau: 5.0,
n_discard: 0,
- ignore_eos: false,
- stream: true,
- logit_bias: [],
+ n_keep: 0,
+ // Add other required fields to match the API type
+ n_predict: 512,
n_probs: 0,
- min_keep: 0,
- grammar: '',
- grammar_lazy: false,
- grammar_triggers: [],
+ post_sampling_probs: false,
+ presence_penalty: 0.0,
preserved_tokens: [],
- chat_format: '',
reasoning_format: '',
reasoning_in_content: false,
- generation_prompt: '',
+ repeat_last_n: 64,
+ repeat_penalty: 1.0,
+ seed: -1,
'speculative.n_max': 0,
'speculative.n_min': 0,
'speculative.p_min': 0.0,
+ stop: [],
+ stream: true,
timings_per_token: false,
- post_sampling_probs: false,
- lora: [],
top_n_sigma: 0.0,
- dry_sequence_breakers: []
+ typ_p: 1.0,
+ xtc_probability: 0.0,
+ xtc_threshold: 0.1
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
// Check that the problematic floating-point values are rounded correctly
it('should preserve non-numeric values', () => {
const mockServerParams = {
- samplers: ['top_k', 'temperature'],
max_tokens: -1,
+ samplers: ['top_k', 'temperature'],
temperature: 0.7
};
-
const result = ParameterSyncService.extractServerDefaults({
...mockServerParams,
- // Minimal required fields
- n_predict: 512,
- seed: -1,
- dynatemp_range: 0.0,
- dynatemp_exponent: 1.0,
- top_k: 40,
- top_p: 0.95,
- min_p: 0.05,
- xtc_probability: 0.0,
- xtc_threshold: 0.1,
- typ_p: 1.0,
- repeat_last_n: 64,
- repeat_penalty: 1.0,
- presence_penalty: 0.0,
- frequency_penalty: 0.0,
- dry_multiplier: 0.0,
- dry_base: 1.75,
+ chat_format: '',
dry_allowed_length: 2,
+ dry_base: 1.75,
+ dry_multiplier: 0.0,
dry_penalty_last_n: 64,
+ dry_sequence_breakers: [],
+ dynatemp_exponent: 1.0,
+ dynatemp_range: 0.0,
+ frequency_penalty: 0.0,
+ generation_prompt: '',
+ grammar: '',
+ grammar_lazy: false,
+ grammar_triggers: [],
+ ignore_eos: false,
+ logit_bias: [],
+ lora: [],
+ min_keep: 0,
+ min_p: 0.05,
mirostat: 0,
- mirostat_tau: 5.0,
mirostat_eta: 0.1,
- stop: [],
- n_keep: 0,
+ mirostat_tau: 5.0,
n_discard: 0,
- ignore_eos: false,
- stream: true,
- logit_bias: [],
+ n_keep: 0,
+ // Minimal required fields
+ n_predict: 512,
n_probs: 0,
- min_keep: 0,
- grammar: '',
- grammar_lazy: false,
- grammar_triggers: [],
+ post_sampling_probs: false,
+ presence_penalty: 0.0,
preserved_tokens: [],
- chat_format: '',
reasoning_format: '',
reasoning_in_content: false,
- generation_prompt: '',
+ repeat_last_n: 64,
+ repeat_penalty: 1.0,
+ seed: -1,
'speculative.n_max': 0,
'speculative.n_min': 0,
'speculative.p_min': 0.0,
+ stop: [],
+ stream: true,
timings_per_token: false,
- post_sampling_probs: false,
- lora: [],
+ top_k: 40,
top_n_sigma: 0.0,
- dry_sequence_breakers: []
+ top_p: 0.95,
+ typ_p: 1.0,
+ xtc_probability: 0.0,
+ xtc_threshold: 0.1
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
expect(result.samplers).toBe('top_k;temperature');
-import { normalizeFloatingPoint } from '$lib/utils';
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
-import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
-import { SyncableParameterType, ParameterSource } from '$lib/enums';
+import { ParameterSource, SyncableParameterType } from '$lib/enums';
+import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
+import { normalizeFloatingPoint } from '$lib/utils';
export class ParameterSyncService {
/**
const value = (serverParams as unknown as Record<string, ParameterValue>)[
param.serverKey
];
+
if (value !== undefined) {
// Apply precision rounding to avoid JavaScript floating-point issues
extracted[param.key] = this.roundFloatingPoint(value);
): ParameterInfo {
const hasPropsDefault = propsDefaults[key] !== undefined;
const isUserOverride = userOverrides.has(key);
-
// Simple logic: either using default (from props) or custom (user override)
const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
return {
- value: currentValue,
- source,
serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
- userOverride: isUserOverride ? currentValue : undefined
+ source,
+ userOverride: isUserOverride ? currentValue : undefined,
+ value: currentValue
};
}
*/
static validateServerParameter(key: string, value: ParameterValue): boolean {
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
+
if (!param) return false;
switch (param.type) {
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
- server: serverValue,
- differs: currentValue !== serverValue
+ differs: currentValue !== serverValue,
+ server: serverValue
};
}
}
*/
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
const params: Record<string, string> = {};
+
if (!autoload) {
params.autoload = 'false';
}
*/
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
const params: Record<string, string> = { model: modelId };
+
if (!autoload) {
params.autoload = 'false';
}
-import { NEWLINE } from '$lib/constants';
import WORKER_SHIM from './sandbox-worker.js?raw';
+import { NEWLINE } from '$lib/constants';
/**
* CSP for the harness document, inherited by the blob worker. connect-src
+import { buildSandboxHarness } from './sandbox-harness';
import {
NEWLINE,
SANDBOX_EMPTY_OUTPUT,
SANDBOX_TOOL_NAME,
SANDBOX_TRUNCATION_NOTICE
} from '$lib/constants';
-import { buildSandboxHarness } from './sandbox-harness';
import { config } from '$lib/stores/settings.svelte';
import type { ToolExecutionResult } from '$lib/types';
async function getHarness(): Promise<string> {
const enabled = !!config().symbolicMathEnabled;
const key = enabled ? 'nerdamer' : 'plain';
+
if (!harnessCache[key]) {
if (enabled) {
const { default: nerdamerJs } = await import('virtual:nerdamer');
+
harnessCache[key] = buildSandboxHarness(nerdamerJs);
} else {
harnessCache[key] = buildSandboxHarness('');
}
}
+
return harnessCache[key];
}
}
let content = lines.join(NEWLINE);
+
if (!content) content = SANDBOX_EMPTY_OUTPUT;
+
if (content.length > SANDBOX_OUTPUT_MAX_CHARS) {
content = `${content.slice(0, SANDBOX_OUTPUT_MAX_CHARS)}${NEWLINE}${SANDBOX_TRUNCATION_NOTICE}`;
}
}
const code = typeof params.code === 'string' ? params.code : '';
+
if (!code) {
return { content: 'Missing required parameter: code', isError: true };
}
const harness = await getHarness();
-
const requested = Number(params.timeout_ms);
const timeoutMs =
Number.isFinite(requested) && requested > 0
return new Promise<ToolExecutionResult>((resolve, reject) => {
const iframe = document.createElement('iframe');
+
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.style.display = 'none';
iframe.srcdoc = harness;
signal?.removeEventListener('abort', onAbort);
iframe.remove();
};
-
const finish = (result: ToolExecutionResult) => {
if (settled) return;
+
cleanup();
resolve(result);
};
-
const onAbort = () => {
if (settled) return;
+
cleanup();
reject(new DOMException('Sandbox execution aborted', 'AbortError'));
};
-
const onMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return;
+
finish(formatReply((event.data ?? {}) as SandboxReply));
};
-
const timer = setTimeout(
() => finish({ content: `Execution timed out after ${timeoutMs} ms`, isError: true }),
timeoutMs
import { base } from '$app/paths';
-import { getJsonHeaders } from '$lib/utils/api-headers';
-import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
-import { apiFetch } from '$lib/utils';
import { API_TOOLS, X_TOOL_CWD_HEADER } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
-import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
+import type { ServerBuiltinToolInfo, ToolExecutionResult } from '$lib/types';
+import { apiFetch } from '$lib/utils';
+import { getJsonHeaders } from '$lib/utils/api-headers';
+import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
export class ToolsService {
/**
cwd?: string
): Promise<ToolExecutionResult> {
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
- method: 'POST',
- body: JSON.stringify({ tool: toolName, params }),
+ body: JSON.stringify({ params, tool: toolName }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
+ method: 'POST',
signal
});
cwd?: string
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
- method: 'POST',
- body: JSON.stringify({ tool: toolName, params }),
+ body: JSON.stringify({ params, tool: toolName }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
+ method: 'POST',
signal
});
}
cwd?: string
): AsyncGenerator<ToolStreamEvent> {
const headers = getJsonHeaders();
+
if (cwd) headers[X_TOOL_CWD_HEADER] = cwd;
+
const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
- method: 'POST',
+ body: JSON.stringify({ params, stream: true, tool: toolName }),
headers,
- body: JSON.stringify({ tool: toolName, params, stream: true }),
+ method: 'POST',
signal
});
if (!response.ok || !response.body) {
const detail = await formatNonOkResponse(response);
+
throw new Error(detail);
}
while (true) {
const next: IteratorResult<SseJsonEvent<ToolServerEvent>> = await iterator.next();
+
if (next.done) return;
+
const event = next.value.data;
if (event.chunk !== undefined) {
yield { chunk: event.chunk, done: false };
}
+
if (event.done) {
yield { chunk: null, done: true, error: event.error };
+
return;
}
}
async function formatNonOkResponse(response: Response): Promise<string> {
const status = `${response.status} ${response.statusText}`.trim();
+
try {
const errBody = (await response.clone().json()) as { error?: string; message?: string };
+
if (errBody?.error) return `${status}: ${errBody.error}`;
+
if (errBody?.message) return `${status}: ${errBody.message}`;
} catch (error) {
console.error('[tools] Non-JSON error response, falling back to raw text:', error);
try {
const text = await response.text();
+
if (text.trim()) return `${status}: ${text.trim()}`;
} catch (error) {
console.error('[tools] Failed to read error response as text:', error);
}
}
+
return status || `HTTP ${response.status}`;
}
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
*/
-import { ChatService } from '$lib/services';
-import { config } from '$lib/stores/settings.svelte';
-import { conversationsStore } from '$lib/stores/conversations.svelte';
-import { mcpStore } from '$lib/stores/mcp.svelte';
-import { modelsStore } from '$lib/stores/models.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
-import { permissionsStore } from '$lib/stores/permissions.svelte';
-import { BuiltInTool, ToolSource, ToolPermissionDecision } from '$lib/enums';
-import { SvelteMap } from 'svelte/reactivity';
-import { ToolsService } from '$lib/services/tools.service';
-import { SandboxService } from '$lib/services/sandbox.service';
-import { isAbortError } from '$lib/utils';
import { DEFAULT_AGENTIC_CONFIG, NEWLINE } from '$lib/constants';
import {
- IMAGE_MIME_TO_EXTENSION,
DATA_URI_BASE64_REGEX,
- MCP_ATTACHMENT_NAME_PREFIX,
- DEFAULT_IMAGE_EXTENSION
+ DEFAULT_IMAGE_EXTENSION,
+ IMAGE_MIME_TO_EXTENSION,
+ MCP_ATTACHMENT_NAME_PREFIX
} from '$lib/constants';
+import { BuiltInTool, ToolPermissionDecision, ToolSource } from '$lib/enums';
import {
AttachmentType,
ContentPartType,
MimeTypePrefix,
ToolCallType
} from '$lib/enums';
+import { ChatService } from '$lib/services';
+import { SandboxService } from '$lib/services/sandbox.service';
+import { ToolsService } from '$lib/services/tools.service';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { permissionsStore } from '$lib/stores/permissions.svelte';
+import { config } from '$lib/stores/settings.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
import type {
+ AgenticConfig,
AgenticFlowParams,
AgenticFlowResult,
AgenticSession,
- AgenticConfig,
- SettingsConfigType,
McpServerOverride,
- MCPToolCall
+ MCPToolCall,
+ SettingsConfigType
} from '$lib/types';
import type {
- AgenticMessage,
- AgenticToolCallList,
AgenticFlowCallbacks,
AgenticFlowOptions,
+ AgenticMessage,
+ AgenticToolCallList,
SteeringMessage
} from '$lib/types/agentic';
import type {
ApiChatCompletionToolCall,
- ApiChatMessageData,
- ApiChatMessageContentPart
+ ApiChatMessageContentPart,
+ ApiChatMessageData
} from '$lib/types/api';
import type {
+ ChatMessageAgenticTimings,
+ ChatMessageAgenticTurnStats,
ChatMessagePromptProgress,
ChatMessageTimings,
- ChatMessageAgenticTimings,
- ChatMessageToolCallTiming,
- ChatMessageAgenticTurnStats
+ ChatMessageToolCallTiming
} from '$lib/types/chat';
import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
+import { isAbortError } from '$lib/utils';
+import { SvelteMap } from 'svelte/reactivity';
function createDefaultSession(): AgenticSession {
return {
- isRunning: false,
currentTurn: 0,
- totalToolCalls: 0,
+ executingToolCallId: null,
+ isRunning: false,
lastError: null,
- streamingToolCall: null,
pendingPermissionRequest: null,
- executingToolCallId: null
+ streamingToolCall: null,
+ totalToolCalls: 0
};
}
message.tool_calls.length > 0
) {
return {
- role: MessageRole.ASSISTANT,
content: message.content,
reasoning_content: message.reasoning_content,
+ role: MessageRole.ASSISTANT,
tool_calls: message.tool_calls.map((call, index) => ({
- id: call.id ?? `call_${index}`,
- type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
function: {
- name: call.function?.name ?? '',
- arguments: call.function?.arguments ?? ''
- }
+ arguments: call.function?.arguments ?? '',
+ name: call.function?.name ?? ''
+ },
+ id: call.id ?? `call_${index}`,
+ type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}))
} satisfies AgenticMessage;
}
+
if (message.role === MessageRole.ASSISTANT) {
return {
- role: MessageRole.ASSISTANT,
content: message.content,
- reasoning_content: message.reasoning_content
+ reasoning_content: message.reasoning_content,
+ role: MessageRole.ASSISTANT
} satisfies AgenticMessage;
}
+
if (message.role === MessageRole.TOOL && message.tool_call_id) {
return {
+ content: typeof message.content === 'string' ? message.content : '',
role: MessageRole.TOOL,
- tool_call_id: message.tool_call_id,
- content: typeof message.content === 'string' ? message.content : ''
+ tool_call_id: message.tool_call_id
} satisfies AgenticMessage;
}
+
return {
- role: message.role as MessageRole.SYSTEM | MessageRole.USER,
- content: message.content
+ content: message.content,
+ role: message.role as MessageRole.SYSTEM | MessageRole.USER
} satisfies AgenticMessage;
});
}
for (const session of this._sessions.values()) {
if (session.isRunning) return true;
}
+
return false;
}
getSession(conversationId: string): AgenticSession {
let session = this._sessions.get(conversationId);
+
if (!session) {
session = createDefaultSession();
this._sessions.set(conversationId, session);
}
+
return session;
}
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
+
this._sessions.set(conversationId, { ...session, ...update });
}
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
+
for (const [conversationId, session] of this._sessions.entries()) {
if (session.isRunning) active.push({ conversationId, session });
}
+
return active;
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this._continueResolvers.get(conversationId);
+
if (resolver) {
this._continueResolvers.delete(conversationId);
resolver(shouldContinue);
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this._permissionResolvers.get(conversationId);
+
if (resolver) {
this._permissionResolvers.delete(conversationId);
resolver(decision);
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this._steeringMessages.get(conversationId);
+
if (!msg) return null;
+
this._steeringMessages.delete(conversationId);
+
return msg;
}
toolsStore.builtinTools.length > 0 ||
toolsStore.frontendTools.length > 0 ||
toolsStore.customTools.length > 0;
+
return {
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
+
const trimmed = args.trim();
+
if (trimmed === '') return {};
+
return JSON.parse(trimmed) as Record<string, unknown>;
}
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
+
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
}
- this._pendingPermissions.set(conversationId, { toolName, serverLabel });
+ this._pendingPermissions.set(conversationId, { serverLabel, toolName });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
+
return;
}
this._permissionResolvers.set(conversationId, (decision) => {
this._pendingPermissions.set(conversationId, null);
+
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
+
permissionsStore.allowTools(serverToolKeys);
}
+
resolve(decision);
});
'abort',
() => {
const resolver = this._permissionResolvers.get(conversationId);
+
if (resolver) {
this._permissionResolvers.delete(conversationId);
this._pendingPermissions.set(conversationId, null);
if (signal?.aborted) {
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
+
return;
}
'abort',
() => {
const resolver = this._continueResolvers.get(conversationId);
+
if (resolver) {
this._continueResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
- const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
+ const { callbacks, conversationId, messages, options = {}, perChatOverrides, signal } = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this._pendingPermissions.set(conversationId, null);
}
const agenticConfig = this.getConfig(config(), perChatOverrides);
+
if (!agenticConfig.enabled) return { handled: false };
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
+
if (hasMcpServers) {
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
}
const tools = toolsStore.getEnabledToolsForLLM();
+
if (tools.length === 0) {
return { handled: false };
}
return ChatService.convertDbMessageToApiChatMessageData(
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
);
+
return msg as ApiChatMessageData;
})
)
).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
if (msg.role === MessageRole.SYSTEM) {
const content = typeof msg.content === 'string' ? msg.content : '';
+
return content.trim().length > 0;
}
+
return true;
});
this.updateSession(conversationId, {
- isRunning: true,
currentTurn: 0,
- totalToolCalls: 0,
- lastError: null
+ isRunning: true,
+ lastError: null,
+ totalToolCalls: 0
});
if (hasMcpServers) mcpStore.acquireConnection();
try {
await this.executeAgenticLoop({
+ agenticConfig,
+ callbacks,
conversationId,
messages: normalizedMessages,
options,
- tools,
- agenticConfig,
- callbacks,
- signal
+ signal,
+ tools
});
+
return { handled: true };
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
+
this.updateSession(conversationId, { lastError: normalizedError });
callbacks.onError?.(normalizedError);
- return { handled: true, error: normalizedError };
+
+ return { error: normalizedError, handled: true };
} finally {
this.updateSession(conversationId, { isRunning: false });
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
}): Promise<void> {
- const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params;
+ const { agenticConfig, callbacks, conversationId, messages, options, signal, tools } = params;
const {
- onChunk,
- onReasoningChunk,
- onToolCallsStreaming,
+ createAssistantMessage,
+ createToolResultMessage,
+ onAssistantTurnComplete,
onAttachments,
- onModel,
+ onChunk,
onCompletionId,
- onAssistantTurnComplete,
- createToolResultMessage,
- updateToolResultMessage,
- createAssistantMessage,
onFlowComplete,
+ onModel,
+ onReasoningChunk,
onTimings,
- onTurnComplete
+ onToolCallsStreaming,
+ onTurnComplete,
+ updateToolResultMessage
} = callbacks;
-
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
+
let capturedTimings: ChatMessageTimings | undefined;
let totalToolCallCount = 0;
const agenticTimings: ChatMessageAgenticTimings = {
- turns: 0,
+ llm: { predicted_ms: 0, predicted_n: 0, prompt_ms: 0, prompt_n: 0 },
+ perTurn: [],
+ toolCalls: [],
toolCallsCount: 0,
toolsMs: 0,
- toolCalls: [],
- perTurn: [],
- llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
+ turns: 0
};
const maxTurns = agenticConfig.maxTurns;
-
const effectiveModel = options.model || modelsStore.models[0]?.model || '';
let turn = 0;
+
while (true) {
if (turn >= maxTurns) {
// Turn limit reached - ask user whether to continue
if (!shouldContinue || signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
let turnTimings: ChatMessageTimings | undefined;
const turnStats: ChatMessageAgenticTurnStats = {
- turn: turn + 1,
- llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 },
+ llm: { predicted_ms: 0, predicted_n: 0, prompt_ms: 0, prompt_n: 0 },
toolCalls: [],
- toolsMs: 0
+ toolsMs: 0,
+ turn: turn + 1
};
try {
sessionMessages as ApiChatMessageData[],
{
...options,
- stream: true,
- tools: tools.length > 0 ? tools : undefined,
onChunk: (chunk: string) => {
turnContent += chunk;
onChunk?.(chunk);
},
+ onComplete: () => {
+ /* Completion handled after sendMessage resolves */
+ },
+ onCompletionId,
+ onError: (error: Error) => {
+ throw error;
+ },
+ onModel,
onReasoningChunk: (chunk: string) => {
turnReasoningContent += chunk;
onReasoningChunk?.(chunk);
},
+ onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
+ onTimings?.(timings, progress);
+
+ if (timings) {
+ capturedTimings = timings;
+ turnTimings = timings;
+ }
+ },
onToolCallChunk: (serialized: string) => {
try {
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
const name = turnToolCalls[0].function.name || '';
const args = turnToolCalls[0].function.arguments || '';
const argsLengthBucket = Math.floor(args.length / 100);
+
if (
name !== lastStreamingToolCallName ||
argsLengthBucket !== lastStreamingToolCallArgsLength
lastStreamingToolCallName = name;
lastStreamingToolCallArgsLength = argsLengthBucket;
this.updateSession(conversationId, {
- streamingToolCall: { name, arguments: args }
+ streamingToolCall: { arguments: args, name }
});
}
}
/* Ignore parse errors during streaming */
}
},
- onModel,
- onCompletionId,
- onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
- onTimings?.(timings, progress);
- if (timings) {
- capturedTimings = timings;
- turnTimings = timings;
- }
- },
- onComplete: () => {
- /* Completion handled after sendMessage resolves */
- },
- onError: (error: Error) => {
- throw error;
- }
+ stream: true,
+ tools: tools.length > 0 ? tools : undefined
},
conversationId,
signal
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
+
const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
+
// preserve partial output as is, the outer error dialog informs the user separately
await onAssistantTurnComplete?.(
turnContent,
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
throw normalizedError;
}
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
turnToolCalls.length > 0 ? this.normalizeToolCalls(turnToolCalls) : undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
// Normalize and save assistant turn with tool calls
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
+
if (normalizedCalls.length === 0) {
await onAssistantTurnComplete?.(
turnContent,
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
// Add assistant message to session history
sessionMessages.push({
- role: MessageRole.ASSISTANT,
content: turnContent || undefined,
reasoning_content: turnReasoningContent || undefined,
+ role: MessageRole.ASSISTANT,
tool_calls: normalizedCalls
});
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
for (let j = i; j < normalizedCalls.length; j++) {
const remainingCall = normalizedCalls[j];
const interruptedContent = 'Tool execution was interrupted by a new user message.';
+
if (createToolResultMessage) {
await createToolResultMessage(remainingCall.id, interruptedContent);
}
+
sessionMessages.push({
+ content: interruptedContent,
role: MessageRole.TOOL,
- tool_call_id: remainingCall.id,
- content: interruptedContent
+ tool_call_id: remainingCall.id
});
}
+
break;
}
const toolName = toolCall.function.name;
const serverLabel = toolsStore.getToolServerLabel(toolName);
-
// Ask for permission before executing the tool
const permission = await this.requestPermission(
conversationId,
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
const args = this.parseToolArguments(toolCall.function.arguments);
const cwd = conversationsStore.activeConversation?.cwd;
const msg = await createToolResultMessage(toolCall.id, '', undefined, cwd);
+
createdToolResultMessageId = msg.id;
let accumulated = '';
+
for await (const ev of ToolsService.streamTool(toolName, args, signal, cwd)) {
if (ev.chunk !== null) {
accumulated += ev.chunk;
await updateToolResultMessage(msg.id, accumulated);
}
+
if (ev.done) {
if (ev.error) {
accumulated = accumulated
await updateToolResultMessage(msg.id, accumulated);
toolSuccess = false;
}
+
break;
}
}
if (executionResult.isError) toolSuccess = false;
} else {
const mcpCall: MCPToolCall = {
- id: toolCall.id,
- function: { name: toolName, arguments: toolCall.function.arguments }
+ function: { arguments: toolCall.function.arguments, name: toolName },
+ id: toolCall.id
};
const executionResult = await mcpStore.executeTool(mcpCall, signal);
if (isAbortError(error)) {
this.updateSession(conversationId, { executingToolCallId: null });
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
+
// Carry the partial stream contents already mirrored to the UI -
// they show up as live output even if the stream broke off mid-run.
result = result
? `${result}\nError: ${error instanceof Error ? error.message : String(error)}`
: `Error: ${error instanceof Error ? error.message : String(error)}`;
toolSuccess = false;
+
if (createdToolResultMessageId && updateToolResultMessage) {
await updateToolResultMessage(createdToolResultMessageId, result);
}
const toolDurationMs = performance.now() - toolStartTime;
const toolTiming: ChatMessageToolCallTiming = {
- name: toolCall.function.name,
duration_ms: Math.round(toolDurationMs),
+ name: toolCall.function.name,
success: toolSuccess
};
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
- const { cleanedResult, attachments } = this.extractBase64Attachments(result);
+ const { attachments, cleanedResult } = this.extractBase64Attachments(result);
// For streaming tools the result message was created empty
// at the start of execution and updated in place as chunks
// the final accumulator (rare, since chunks usually don't
// carry image data URIs) and emit the attachments callback.
let toolResultMessage: DatabaseMessage | undefined;
+
if (createdToolResultMessageId) {
toolResultMessage = { id: createdToolResultMessageId } as DatabaseMessage;
+
if (attachments.length > 0 && updateToolResultMessage) {
await updateToolResultMessage(createdToolResultMessageId, cleanedResult, attachments);
}
// Build content parts for session history (including images for vision models)
const contentParts: ApiChatMessageContentPart[] = [
- { type: ContentPartType.TEXT, text: cleanedResult }
+ { text: cleanedResult, type: ContentPartType.TEXT }
];
+
for (const attachment of attachments) {
if (attachment.type === AttachmentType.IMAGE) {
if (modelsStore.modelSupportsVision(effectiveModel)) {
contentParts.push({
- type: ContentPartType.IMAGE_URL,
image_url: {
url: (attachment as DatabaseMessageExtraImageFile).base64Url
- }
+ },
+ type: ContentPartType.IMAGE_URL
});
} else {
console.info(
}
sessionMessages.push({
+ content: contentParts.length === 1 ? cleanedResult : contentParts,
role: MessageRole.TOOL,
- tool_call_id: toolCall.id,
- content: contentParts.length === 1 ? cleanedResult : contentParts
+ tool_call_id: toolCall.id
});
}
agenticTimings.perTurn!.push(turnStats);
const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
+
if (intermediateTimings) onTurnComplete?.(intermediateTimings);
}
'[AgenticStore] Steering message detected after tool execution, exiting agentic flow'
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+
return;
}
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
+
return {
- predicted_n: capturedTimings?.predicted_n,
+ agentic: agenticTimings,
+ cache_n: capturedTimings?.cache_n,
predicted_ms: capturedTimings?.predicted_ms,
- prompt_n: capturedTimings?.prompt_n,
+ predicted_n: capturedTimings?.predicted_n,
prompt_ms: capturedTimings?.prompt_ms,
- cache_n: capturedTimings?.cache_n,
- agentic: agenticTimings
+ prompt_n: capturedTimings?.prompt_n
};
}
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
+
return toolCalls.map((call, index) => ({
- id: call?.id ?? `tool_${index}`,
- type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
function: {
- name: call?.function?.name ?? '',
- arguments: call?.function?.arguments ?? ''
- }
+ arguments: call?.function?.arguments ?? '',
+ name: call?.function?.name ?? ''
+ },
+ id: call?.id ?? `tool_${index}`,
+ type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}));
}
attachments: DatabaseMessageExtra[];
} {
if (!result.trim()) {
- return { cleanedResult: result, attachments: [] };
+ return { attachments: [], cleanedResult: result };
}
const lines = result.split(NEWLINE);
const attachments: DatabaseMessageExtra[] = [];
+
let attachmentIndex = 0;
const cleanedLines = lines.map((line) => {
const trimmedLine = line.trim();
-
const match = trimmedLine.match(DATA_URI_BASE64_REGEX);
+
if (!match) {
return line;
}
const name = this.buildAttachmentName(mimeType, attachmentIndex);
if (mimeType.startsWith(MimeTypePrefix.IMAGE)) {
- attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
+ attachments.push({ base64Url: trimmedLine, name, type: AttachmentType.IMAGE });
return `[Attachment saved: ${name}]`;
}
return line;
});
- return { cleanedResult: cleanedLines.join(NEWLINE), attachments };
+ return { attachments, cleanedResult: cleanedLines.join(NEWLINE) };
}
private buildAttachmentName(mimeType: string, index: number): string {
if (import.meta.env.DEV) {
build = 'dev';
+
return;
}
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
+
if (res.ok) {
const data = await res.json();
+
build = data.version ?? '';
}
} catch {
* @see ChatService in services/chat.service.ts for API operations
*/
-import { SvelteMap, SvelteSet } from 'svelte/reactivity';
-import { DatabaseService } from '$lib/services/database.service';
-import { ChatService } from '$lib/services/chat.service';
-import { STREAM_RESUME_RETRY_MS } from '$lib/constants/api-endpoints';
-import { streamIdentity } from '$lib/utils/stream-identity';
-import { getAuthHeaders } from '$lib/utils/api-headers';
import { CONTENT_TYPE_HEADER } from '$lib/constants';
+import {
+ INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
+ MAX_INACTIVE_CONVERSATION_STATES,
+ SYSTEM_MESSAGE_PLACEHOLDER,
+ TITLE_GENERATION
+} from '$lib/constants';
+import { STREAM_RESUME_RETRY_MS } from '$lib/constants/api-endpoints';
import { MimeTypeApplication } from '$lib/enums';
-import { conversationsStore } from '$lib/stores/conversations.svelte';
-import { config } from '$lib/stores/settings.svelte';
+import {
+ ContinueIntentKind,
+ ErrorDialogType,
+ MessageRole,
+ MessageType,
+ ReasoningEffort,
+ StreamConnectionState
+} from '$lib/enums';
+import { ChatService } from '$lib/services/chat.service';
+import { DatabaseService } from '$lib/services/database.service';
import { agenticStore } from '$lib/stores/agentic.svelte';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
-import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
import {
- selectedModelName,
modelsStore,
- selectedModelContextSize
+ selectedModelContextSize,
+ selectedModelName
} from '$lib/stores/models.svelte';
-import {
- normalizeModelName,
- filterByLeafNodeId,
- findDescendantMessages,
- findLeafNode,
- findMessageById,
- formatCwdMessage,
- isAbortError,
- generateConversationTitle,
- CWD_CLEARED_TEXT
-} from '$lib/utils';
+import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
+import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
-import { classifyContinueIntent } from '$lib/utils/agentic';
-import {
- MAX_INACTIVE_CONVERSATION_STATES,
- INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
- SYSTEM_MESSAGE_PLACEHOLDER,
- TITLE_GENERATION
-} from '$lib/constants';
-import type {
- ChatMessageTimings,
- ChatMessagePromptProgress,
- ChatStreamCallbacks,
- ErrorDialogState
-} from '$lib/types/chat';
import type {
ApiChatMessageData,
ApiProcessingState,
DatabaseMessage,
DatabaseMessageExtra
} from '$lib/types';
+import type {
+ ChatMessagePromptProgress,
+ ChatMessageTimings,
+ ChatStreamCallbacks,
+ ErrorDialogState
+} from '$lib/types/chat';
import {
- ContinueIntentKind,
- ErrorDialogType,
- MessageRole,
- MessageType,
- ReasoningEffort,
- StreamConnectionState
-} from '$lib/enums';
+ CWD_CLEARED_TEXT,
+ filterByLeafNodeId,
+ findDescendantMessages,
+ findLeafNode,
+ findMessageById,
+ formatCwdMessage,
+ generateConversationTitle,
+ isAbortError,
+ normalizeModelName
+} from '$lib/utils';
+import { classifyContinueIntent } from '$lib/utils/agentic';
+import { getAuthHeaders } from '$lib/utils/api-headers';
+import { streamIdentity } from '$lib/utils/stream-identity';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface ConversationStateEntry {
lastAccessed: number;
private setChatLoading(convId: string, loading: boolean): void {
this.touchConversationState(convId);
+
if (loading) {
this.chatLoadingStates.set(convId, true);
+
if (convId === conversationsStore.activeConversation?.id) this.isLoading = true;
} else {
this.chatLoadingStates.delete(convId);
+
if (convId === conversationsStore.activeConversation?.id) this.isLoading = false;
+
this.setChatReasoning(convId, false);
// the local pipe is the authoritative observer of session end: when it finishes (clean
// onComplete or explicit Stop), the backend session is finalized too, so we drop the
private setChatReasoning(convId: string, reasoning: boolean): void {
if (reasoning) {
this.chatReasoningStates.set(convId, true);
+
if (convId === conversationsStore.activeConversation?.id) this.isReasoning = true;
} else {
this.chatReasoningStates.delete(convId);
+
if (convId === conversationsStore.activeConversation?.id) this.isReasoning = false;
}
}
): void {
this.touchConversationState(convId);
this.chatStreamingStates.set(convId, {
- response,
messageId,
- model: model ?? this.chatStreamingStates.get(convId)?.model
+ model: model ?? this.chatStreamingStates.get(convId)?.model,
+ response
});
+
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
}
private clearChatStreaming(convId: string, messageId?: string): void {
// same conversation, that would drop the frozen stop identity and stop the wrong session
if (messageId !== undefined) {
const cur = this.chatStreamingStates.get(convId);
+
if (cur && cur.messageId !== messageId) return;
}
+
this.chatStreamingStates.delete(convId);
+
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
}
private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
this.isLoading = this.chatLoadingStates.get(convId) || false;
this.isReasoning = this.chatReasoningStates.get(convId) || false;
const s = this.chatStreamingStates.get(convId);
+
this.currentResponse = s?.response || '';
this.isStreamingActive = s !== undefined;
this.setActiveProcessingConversation(convId);
+
// Sync streaming content to activeMessages so UI displays current content
if (s?.response && s?.messageId) {
const idx = conversationsStore.findMessageIndex(s.messageId);
+
if (idx !== -1) {
conversationsStore.updateMessageAtIndex(idx, { content: s.response });
}
*/
async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
if (!convId) return null;
+
let listResp: Response;
+
try {
// POST the one conv id we are probing
listResp = await fetch(`./v1/streams/lookup`, {
- method: 'POST',
+ body: JSON.stringify({ conversation_ids: [convId] }),
headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
- body: JSON.stringify({ conversation_ids: [convId] })
+ method: 'POST'
});
} catch (e) {
console.warn('probeServerStream fetch failed:', e);
+
return null;
}
+
if (!listResp.ok) {
console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`);
+
return null;
}
+
let sessions: ApiStreamSession[];
+
try {
sessions = (await listResp.json()) as ApiStreamSession[];
} catch (e) {
console.warn('probeServerStream JSON parse failed:', e);
+
return null;
}
+
return ChatService.selectActiveStream(sessions);
}
async attachServerStream(convId: string, streamId?: string): Promise<void> {
if (!convId) return;
+
if (this.chatStreamingStates.has(convId)) return;
// flip the spinner immediately, the user sees activity as soon as the conv becomes active.
this.setChatLoading(convId, true);
this.attachingConvs.add(convId);
this.setStreamingActive(true);
+
// only set the active processing conv if we are looking at it, otherwise a background
// attach would steal the indicator from the conv the user is currently viewing
if (convId === conversationsStore.activeConversation?.id) {
const unlock = () => {
this.attachingConvs.delete(convId);
+
// flip the global flag off only when no other conv is still attaching
if (this.attachingConvs.size === 0) {
this.setStreamingActive(false);
}
+
this.setChatLoading(convId, false);
this.clearChatStreaming(convId);
};
-
// fetch the replay stream from byte 0, rebuild the assistant message from scratch.
// resolve the server side identity, fall back to streamIdentity when the caller does not
// pass a streamId. probeServerStream returns the full id (with ::model suffix when present)
const id = streamId || streamIdentity(convId, selectedModelName());
+
let response: Response;
+
try {
response = await fetch(`./v1/stream?conv_id=${encodeURIComponent(id)}&from=0`, {
headers: getAuthHeaders()
} catch (e) {
console.error('attachServerStream replay fetch failed:', e);
unlock();
+
return;
}
+
if (!response.ok) {
console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`);
unlock();
+
return;
}
// DB we stay isolated, and only mirror into the active store when the attached conv is
// the one currently displayed
let messages: DatabaseMessage[];
+
try {
messages = await DatabaseService.getConversationMessages(convId);
} catch (e) {
console.error('attachServerStream load messages failed:', e);
unlock();
+
return;
}
// locate the slot to splice into, create a placeholder assistant message if there is none.
// we use the conv-scoped findLastAssistantIdx helpers, they only depend on the array
let targetIdx = this.findLastAssistantIdx(messages);
+
if (targetIdx === -1) {
const lastUserIdx = this.findLastUserIdx(messages);
+
if (lastUserIdx === -1) {
console.warn(
`attachServerStream: conv ${convId} has no user or assistant message, cannot splice`
);
unlock();
+
return;
}
+
try {
const placeholder = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: '',
convId,
+ parent: messages[lastUserIdx].id,
role: MessageRole.ASSISTANT,
- content: '',
- type: MessageType.TEXT,
timestamp: Date.now(),
- parent: messages[lastUserIdx].id,
- children: [],
- toolCalls: ''
+ toolCalls: '',
+ type: MessageType.TEXT
} as Omit<DatabaseMessage, 'id'>,
messages[lastUserIdx].id
);
+
messages = [...messages, placeholder];
targetIdx = messages.length - 1;
+
// only push into the active store when this conv is the one displayed right now
if (convId === conversationsStore.activeConversation?.id) {
conversationsStore.addMessageToActive(placeholder);
} catch (e) {
console.error('attachServerStream placeholder creation failed:', e);
unlock();
+
return;
}
}
+
if (targetIdx === -1) {
unlock();
+
return;
}
+
const targetMessage = messages[targetIdx];
const targetMessageId = targetMessage.id;
// when the assistant slot already has content, the running session is a continue or
const existingContent = targetMessage.content ?? '';
const existingReasoning = targetMessage.reasoningContent ?? '';
const isAppendMode = existingContent.length > 0;
-
// helper: write to the active store only when the attached conv is currently displayed.
// the lookup by message id is robust to reordering of activeMessages, two parallel attaches
// can no longer step on each other's indices
if (convId !== conversationsStore.activeConversation?.id) {
return;
}
+
const liveIdx = conversationsStore.findMessageIndex(targetMessageId);
+
if (liveIdx === -1) return;
+
conversationsStore.updateMessageAtIndex(liveIdx, updates);
};
// the session was tagged with, not the live dropdown
const sepIdx = id.indexOf('::');
const attachedModel: string | null = sepIdx === -1 ? null : id.slice(sepIdx + 2);
+
this.setChatStreaming(convId, existingContent, targetMessageId, attachedModel);
const abortController = this.getOrCreateAbortController(convId);
(chunk: string) => {
streamedContent += chunk;
const displayed = isAppendMode ? existingContent + streamedContent : streamedContent;
+
writeActive({ content: displayed });
this.setChatStreaming(convId, displayed, targetMessageId);
},
const streamedR = streamedReasoningContent || reasoningContent || '';
const content = isAppendMode ? existingContent + streamed : streamed;
const reasoning = isAppendMode ? existingReasoning + streamedR : streamedR;
+
// the DB write is the source of truth, mirror to the active store only when
// the conv is currently displayed
await DatabaseService.updateMessage(targetMessageId, {
content,
reasoningContent: reasoning || undefined,
- toolCalls: toolCalls || '',
- timings
+ timings,
+ toolCalls: toolCalls || ''
});
writeActive({
content,
const displayed = isAppendMode
? existingReasoning + streamedReasoningContent
: streamedReasoningContent;
+
writeActive({ reasoningContent: displayed });
},
undefined,
async discoverActiveStream(convId: string): Promise<void> {
if (!convId) return;
+
if (this.chatStreamingStates.has(convId)) return;
+
if (this.chatLoadingStates.get(convId) && !this.resumePendingConvs.has(convId)) return;
+
// concurrency guard: another discover may already be running for this conv (typical race
// between mount and visibilitychange on tab switch). a second concurrent fetch on the same
// /v1/stream would duplicate every byte into the DB message, this guard bounces it
if (this.discoveringConvs.has(convId)) return;
+
this.discoveringConvs.add(convId);
try {
// model conv with no ::suffix, only guess from the dropdown with no persisted state
const localState = ChatService.getStreamState(convId);
const streamId = ChatService.resumeStreamIdentity(convId, localState, selectedModelName());
-
// primary path: ask the server which sessions exist for this identity
const serverTarget = await this.probeServerStream(streamId);
+
if (serverTarget) {
// pass the full server side identity (may carry a ::model suffix) so the GET routes
// straight to the owning session, no probe or fan out
await this.attachServerStream(convId, serverTarget.conversation_id);
+
return;
}
if (!localState) {
return;
}
+
// quiet status probe first: a full attach flips the loading UI on every try, probing
// keeps the retry loop invisible while the owning model is still loading (503)
const status = await ChatService.probeResumeStatus(streamId);
+
if (status === 503) {
// make the wait visible: the empty assistant row persisted at send time renders
// the processing info, whose model load percentage flows from the models feed
this.resumePendingConvs.add(convId);
this.setChatLoading(convId, true);
+
if (!this.resumeRetryTimers.has(convId)) {
this.resumeRetryTimers.set(
convId,
}, STREAM_RESUME_RETRY_MS)
);
}
+
return;
}
+
if (this.resumePendingConvs.delete(convId) && status !== 200) {
// the wait is over without a session to attach, drop the visible loading state
this.setChatLoading(convId, false);
}
+
if (status === 0) {
// transient network failure, the next mount or visibility change retries
return;
}
+
if (status !== 200) {
// the session is gone (stopped, TTL expired), nothing to resume anymore
ChatService.clearStreamState(convId);
+
return;
}
+
await this.attachServerStream(convId, streamId);
+
// if attachServerStream failed (session gone, TTL expired), clear the local state to avoid retrying forever
if (!this.chatStreamingStates.has(convId) && !this.chatLoadingStates.get(convId)) {
ChatService.clearStreamState(convId);
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.ASSISTANT) return i;
}
+
return -1;
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.USER) return i;
}
+
return -1;
}
private setProcessingState(conversationId: string, state: ApiProcessingState | null): void {
if (state === null) this.processingStates.delete(conversationId);
else this.processingStates.set(conversationId, state);
+
if (conversationId === this.activeConversationId) this.activeProcessingState = state;
}
clearProcessingState(conversationId: string): void {
this.processingStates.delete(conversationId);
+
if (conversationId === this.activeConversationId) this.activeProcessingState = null;
}
private getOrCreateAbortController(convId: string): AbortController {
let c = this.abortControllers.get(convId);
+
if (!c || c.signal.aborted) {
c = new AbortController();
this.abortControllers.set(convId, c);
}
+
return c;
}
private abortRequest(convId?: string): void {
if (convId) {
const c = this.abortControllers.get(convId);
+
if (c) {
c.abort();
this.abortControllers.delete(convId);
async abortCurrentFlow(convId: string): Promise<void> {
await this.savePartialResponseIfNeeded(convId);
const c = this.abortControllers.get(convId);
+
if (c) {
c.abort();
this.abortControllers.delete(convId);
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null;
- const d = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] };
+
+ const d = { files: [...this._pendingDraftFiles], message: this._pendingDraftMessage };
+
this._pendingDraftMessage = '';
this._pendingDraftFiles = [];
+
return d;
}
// union of local (this browser is piping) and remote (backend reports a running session
// for this conv but no local pipe yet) sources. the sidebar shows one spinner per entry
const out = new SvelteSet<string>(this.chatLoadingStates.keys());
+
for (const id of this.remoteRunningConvs) {
out.add(id);
}
+
return Array.from(out);
}
// on the store init race, and the sidebar spinners light up at first paint for every conv
// the user owns even if it has not been hydrated into the store yet
let ids: string[];
+
try {
const all = await DatabaseService.getAllConversations();
+
ids = all.map((c) => c.id).filter((id) => !!id);
} catch (e) {
console.warn('syncRemoteRunningStreams DB read failed:', e);
+
return;
}
+
// only ask about conv ids the user already owns
if (ids.length === 0) {
for (const id of Array.from(this.remoteRunningConvs)) {
this.remoteRunningConvs.delete(id);
}
+
return;
}
+
// rebuild the frozen conv::model identity per conv so a session started with a model still
// matches. the server response is mapped back to the bare id below for the sidebar set
const lookupIds = ids.map((id) =>
ChatService.resumeStreamIdentity(id, ChatService.getStreamState(id), null)
);
+
let sessions: ApiStreamSession[];
+
try {
const resp = await fetch('./v1/streams/lookup', {
- method: 'POST',
+ body: JSON.stringify({ conversation_ids: lookupIds }),
headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
- body: JSON.stringify({ conversation_ids: lookupIds })
+ method: 'POST'
});
+
if (!resp.ok) return;
+
const body = (await resp.json()) as unknown;
+
if (!Array.isArray(body)) return;
+
sessions = body as ApiStreamSession[];
} catch (e) {
console.warn('syncRemoteRunningStreams fetch failed:', e);
+
return;
}
const running = new SvelteSet<string>();
+
for (const s of sessions) {
if (s && !s.is_done && typeof s.conversation_id === 'string' && s.conversation_id) {
// strip the optional ::model suffix, the sidebar set is keyed by the bare conv id
const sepIdx = s.conversation_id.indexOf('::');
const bareId = sepIdx === -1 ? s.conversation_id : s.conversation_id.slice(0, sepIdx);
+
running.add(bareId);
}
}
convId: string
): { content: string; extras?: DatabaseMessageExtra[] } | null {
const msg = this._pendingMessages.get(convId);
+
if (!msg) return null;
+
this._pendingMessages.delete(convId);
+
return msg;
}
])
];
const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = [];
+
for (const convId of allConvIds) {
if (preserveIds.includes(convId)) continue;
+
if (this.chatLoadingStates.get(convId)) continue;
+
if (this.chatStreamingStates.has(convId)) continue;
+
const ts = this.conversationStateTimestamps.get(convId);
+
cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 });
}
cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
let cleanedUp = 0;
+
for (const { convId, lastAccessed } of cleanupCandidates) {
if (
cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES ||
cleanedUp++;
}
}
+
return cleanedUp;
}
private cleanupConversationState(convId: string): void {
const c = this.abortControllers.get(convId);
+
if (c && !c.signal.aborted) c.abort();
+
this.chatLoadingStates.delete(convId);
this.chatStreamingStates.delete(convId);
this.abortControllers.delete(convId);
expectedRole?: MessageRole
): { message: DatabaseMessage; index: number } | null {
const index = conversationsStore.findMessageIndex(messageId);
+
if (index === -1) return null;
+
const message = conversationsStore.activeMessages[index];
+
if (expectedRole && message.role !== expectedRole) return null;
- return { message, index };
+
+ return { index, message };
}
async addMessage(
isSynthetic?: boolean
): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) throw new Error('No active conversation');
+
let parentId: string | null = null;
+
if (parent === '-1') {
const am = conversationsStore.activeMessages;
+
if (am.length > 0) parentId = am[am.length - 1].id;
else {
const all = await conversationsStore.getConversationMessages(activeConv.id);
const r = all.find((m) => m.parent === null && m.type === 'root');
+
parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id);
}
} else parentId = parent;
+
const message = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content,
convId: activeConv.id,
+ extra: extras,
+ isSynthetic,
role,
- content,
- type,
timestamp: Date.now(),
toolCalls: '',
- children: [],
- extra: extras,
- isSynthetic
+ type
},
parentId
);
+
conversationsStore.addMessageToActive(message);
await conversationsStore.updateCurrentNode(message.id);
conversationsStore.updateConversationTimestamp();
+
return message;
}
const content = cwd
? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
: CWD_CLEARED_TEXT;
-
// Reuse the trailing cwd row when it is already the last message, so
// repeated picks update it in place instead of stacking another row.
const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
+
if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
content,
isSynthetic: true
});
+
return;
}
async addSystemPrompt(): Promise<void> {
let activeConv = conversationsStore.activeConversation;
+
if (!activeConv) {
await conversationsStore.createConversation();
activeConv = conversationsStore.activeConversation;
}
+
if (!activeConv) return;
+
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const existingSystemMessage = allMessages.find(
(m) => m.role === MessageRole.SYSTEM && m.parent === rootId
);
+
if (existingSystemMessage) {
this.pendingEditMessageId = existingSystemMessage.id;
+
if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id))
conversationsStore.activeMessages.unshift(existingSystemMessage);
+
return;
}
+
const am = conversationsStore.activeMessages;
const firstActiveMessage = am.find((m) => m.parent === rootId);
const systemMessage = await DatabaseService.createSystemMessage(
SYSTEM_MESSAGE_PLACEHOLDER,
rootId
);
+
if (firstActiveMessage) {
await DatabaseService.updateMessage(firstActiveMessage.id, {
parent: systemMessage.id
const updatedRootChildren = rootMessage
? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id)
: [];
+
await DatabaseService.updateMessage(rootId, {
children: [
...updatedRootChildren.filter((id: string) => id !== systemMessage.id),
]
});
const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id);
+
if (firstMsgIndex !== -1)
conversationsStore.updateMessageAtIndex(firstMsgIndex, {
parent: systemMessage.id
});
}
+
conversationsStore.activeMessages.unshift(systemMessage);
this.pendingEditMessageId = systemMessage.id;
conversationsStore.updateConversationTimestamp();
async removeSystemPromptPlaceholder(messageId: string): Promise<boolean> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return false;
+
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const systemMessage = findMessageById(allMessages, messageId);
+
if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false;
+
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+
if (!rootMessage) return false;
+
if (allMessages.length === 2 && systemMessage.children.length === 0) {
await conversationsStore.deleteConversation(activeConv.id);
+
return true;
}
+
for (const childId of systemMessage.children) {
await DatabaseService.updateMessage(childId, { parent: rootMessage.id });
const childIndex = conversationsStore.findMessageIndex(childId);
+
if (childIndex !== -1)
conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id });
}
});
await DatabaseService.deleteMessage(messageId);
const systemIndex = conversationsStore.findMessageIndex(messageId);
+
if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1);
+
conversationsStore.updateConversationTimestamp();
+
return false;
} catch (error) {
console.error('Failed to remove system prompt placeholder:', error);
+
return false;
}
}
private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) throw new Error('No active conversation');
+
return await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: '',
convId: activeConv.id,
- type: MessageType.TEXT,
+ model: null,
role: MessageRole.ASSISTANT,
- content: '',
timestamp: Date.now(),
toolCalls: '',
- children: [],
- model: null
+ type: MessageType.TEXT
},
parentId || null
);
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if (!content.trim() && (!extras || extras.length === 0)) return;
+
const activeConv = conversationsStore.activeConversation;
// If agentic loop is running, inject as a steering message instead of starting a new flow
if (activeConv && agenticStore.isRunning(activeConv.id)) {
agenticStore.injectSteeringMessage(activeConv.id, content, extras);
+
return;
}
// If non-agentic streaming is active, queue as a pending message to send after completion
if (activeConv && this.isChatLoadingInternal(activeConv.id)) {
this.injectPendingMessage(activeConv.id, content, extras);
+
return;
}
const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras;
let isNewConversation = false;
+
if (!activeConv) {
await conversationsStore.createConversation();
isNewConversation = true;
}
+
const currentConv = conversationsStore.activeConversation;
+
if (!currentConv) return;
+
this.showErrorDialog(null);
this.setChatLoading(currentConv.id, true);
this.clearChatStreaming(currentConv.id);
try {
let parentIdForUserMessage: string | undefined;
+
if (isNewConversation) {
const rootId = await DatabaseService.createRootMessage(currentConv.id);
const currentConfig = config();
const systemPrompt = currentConfig.systemMessage?.toString().trim();
+
let sysOrRootId = rootId;
+
if (systemPrompt) {
const systemMessage = await DatabaseService.createSystemMessage(
currentConv.id,
systemPrompt,
rootId
);
+
conversationsStore.addMessageToActive(systemMessage);
sysOrRootId = systemMessage.id;
}
+
// Reflect a working directory picked on the new-chat screen into
// chat history before the first user message, so the model sees
// it on its first turn. createConversation() has already threaded
undefined,
true
);
+
parentIdForUserMessage = cwdMessage.id;
} else {
parentIdForUserMessage = sysOrRootId;
}
}
+
const userMessage = await this.addMessage(
MessageRole.USER,
content,
parentIdForUserMessage ?? '-1',
allExtras
);
+
if (isNewConversation && content)
await conversationsStore.updateConversationName(
currentConv.id,
generateConversationTitle(content, Boolean(config().titleGenerationUseFirstLine))
);
+
const assistantMessage = await this.createAssistantMessage(userMessage.id);
+
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
} catch (error) {
if (isAbortError(error)) {
this.setChatLoading(currentConv.id, false);
+
return;
}
+
console.error('Failed to send message:', error);
this.setChatLoading(currentConv.id, false);
const dialogType =
const contextInfo = (
error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
).contextInfo;
+
this.showErrorDialog({
- type: dialogType,
+ contextInfo,
message: error instanceof Error ? error.message : 'Unknown error',
- contextInfo
+ type: dialogType
});
}
}
if (isRouterMode()) {
const conversationModel = this.getConversationModel(allMessages);
+
effectiveModel = modelOverride || selectedModelName() || conversationModel;
}
let streamedReasoningContent = '';
let resolvedModel: string | null = null;
let modelPersisted = false;
+
const convId = assistantMessage.convId;
+
// Tracks the last message created in this flow. Used as the parent for the next
// turn's assistant message so createAssistantMessage does not have to read
// conversationsStore.activeMessages, which may belong to a different conversation
// after the user navigates while the loop is still running.
let lastCreatedInFlow = currentMessageId;
+
// freeze the POST identity from t0 so a stop cancels with the exact session key,
// never a stale or empty model resolved later
this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
if (!modelName) return;
+
const n = normalizeModelName(modelName);
+
if (!n || n === resolvedModel) return;
+
resolvedModel = n;
const idx = conversationsStore.findMessageIndex(currentMessageId);
+
conversationsStore.updateMessageAtIndex(idx, { model: n });
+
if (persistImmediately && !modelPersisted) {
modelPersisted = true;
DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
};
let completionIdRecorded = false;
+
const recordCompletionId = (id: string): void => {
if (!id || completionIdRecorded) return;
+
completionIdRecorded = true;
const idx = conversationsStore.findMessageIndex(currentMessageId);
+
conversationsStore.updateMessageAtIndex(idx, { completionId: id });
DatabaseService.updateMessage(currentMessageId, { completionId: id }).catch(() => {
completionIdRecorded = false;
});
};
-
const updateStreamingUI = () => {
this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
const idx = conversationsStore.findMessageIndex(currentMessageId);
+
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
};
-
const cleanupStreamingState = () => {
this.setStreamingActive(false);
this.setChatLoading(convId, false);
this.setStreamingActive(true);
this.setActiveProcessingConversation(convId);
const abortController = this.getOrCreateAbortController(convId);
-
const streamCallbacks: ChatStreamCallbacks = {
- onChunk: (chunk: string) => {
- streamedContent += chunk;
- updateStreamingUI();
- this.setChatReasoning(convId, false);
- },
- onReasoningChunk: (chunk: string) => {
- streamedReasoningContent += chunk;
- // mark streaming state so a stop mid-thinking can persist the partial reasoning
- this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
- const idx = conversationsStore.findMessageIndex(currentMessageId);
- conversationsStore.updateMessageAtIndex(idx, {
- reasoningContent: streamedReasoningContent
- });
- this.setChatReasoning(convId, true);
- },
- onToolCallsStreaming: (toolCalls) => {
- const idx = conversationsStore.findMessageIndex(currentMessageId);
- conversationsStore.updateMessageAtIndex(idx, {
- toolCalls: JSON.stringify(toolCalls)
- });
- },
- onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => {
- if (!extras.length) return;
- const idx = conversationsStore.findMessageIndex(messageId);
- if (idx === -1) return;
- const msg = conversationsStore.activeMessages[idx];
- const updatedExtras = [...(msg.extra || []), ...extras];
- conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
- DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
- },
- onModel: (modelName: string) => recordModel(modelName),
- onCompletionId: (id: string) => recordCompletionId(id),
- onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
- // Update the first assistant message with cumulative agentic timings
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
- conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
- },
- onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
- const tokensPerSecond =
- timings?.predicted_ms && timings?.predicted_n
- ? (timings.predicted_n / timings.predicted_ms) * 1000
- : 0;
- this.updateProcessingStateFromTimings(
+ createAssistantMessage: async () => {
+ // Reset streaming state for new message
+ streamedContent = '';
+ streamedReasoningContent = '';
+
+ const msg = await DatabaseService.createMessageBranch(
{
- prompt_n: timings?.prompt_n || 0,
- prompt_ms: timings?.prompt_ms,
- predicted_n: timings?.predicted_n || 0,
- predicted_per_second: tokensPerSecond,
- cache_n: timings?.cache_n || 0,
- prompt_progress: promptProgress
+ children: [],
+ content: '',
+ convId,
+ model: resolvedModel,
+ role: MessageRole.ASSISTANT,
+ timestamp: Date.now(),
+ toolCalls: '',
+ type: MessageType.TEXT
},
- convId
+ lastCreatedInFlow
);
- },
- onAssistantTurnComplete: async (
- content: string,
- reasoningContent: string | undefined,
- timings: ChatMessageTimings | undefined,
- toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
- ) => {
- const updateData: Record<string, unknown> = {
- content,
- reasoningContent: reasoningContent || undefined,
- toolCalls: toolCalls ? JSON.stringify(toolCalls) : '',
- timings
- };
- if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
- await DatabaseService.updateMessage(currentMessageId, updateData);
- const idx = conversationsStore.findMessageIndex(currentMessageId);
- const uiUpdate: Partial<DatabaseMessage> = {
- content,
- reasoningContent: reasoningContent || undefined,
- toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
- };
- if (timings) uiUpdate.timings = timings;
- if (resolvedModel) uiUpdate.model = resolvedModel;
- // touch the active ui array and node pointer only when this conversation
- // is displayed; otherwise persist the node move straight to the db so a
- // foreign conv's currNode stays untouched
+
if (conversationsStore.activeConversation?.id === convId) {
- conversationsStore.updateMessageAtIndex(idx, uiUpdate);
- await conversationsStore.updateCurrentNode(currentMessageId);
- } else {
- await DatabaseService.updateCurrentNode(convId, currentMessageId);
+ conversationsStore.addMessageToActive(msg);
}
+
+ currentMessageId = msg.id;
+ lastCreatedInFlow = msg.id;
+
+ return msg;
},
createToolResultMessage: async (
toolCallId: string,
) => {
const msg = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content,
convId,
- type: MessageType.TEXT,
+ extra: extras,
role: MessageRole.TOOL,
- content,
- toolCallId,
- toolCwd,
timestamp: Date.now(),
+ toolCallId,
toolCalls: '',
- children: [],
- extra: extras
+ toolCwd,
+ type: MessageType.TEXT
},
currentMessageId
);
+
// mirror into the active store and move the node pointer only when this
// conversation is displayed; otherwise persist the node move straight to
// the db for the owning conv so a foreign conv's currNode stays untouched
} else {
await DatabaseService.updateCurrentNode(convId, msg.id);
}
+
lastCreatedInFlow = msg.id;
+
return msg;
},
- updateToolResultMessage: async (
- messageId: string,
+ onAssistantTurnComplete: async (
content: string,
- extras?: DatabaseMessageExtra[]
+ reasoningContent: string | undefined,
+ timings: ChatMessageTimings | undefined,
+ toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
) => {
- // Persist latest content + merged extras; mirror into the active
- // store so the chat view sees live updates for streaming tools
- // (e.g. exec_shell_command). The existing tool message node
- // pointer stays put - the renderer is already scoped to it.
- const updates: Partial<DatabaseMessage> = { content };
- if (extras) {
- const idx = conversationsStore.findMessageIndex(messageId);
- const existing = idx >= 0 ? (conversationsStore.activeMessages[idx]?.extra ?? []) : [];
- const merged = [...existing, ...extras];
- updates.extra = merged;
- }
+ const updateData: Record<string, unknown> = {
+ content,
+ reasoningContent: reasoningContent || undefined,
+ timings,
+ toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
+ };
+
+ if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
+
+ await DatabaseService.updateMessage(currentMessageId, updateData);
+ const idx = conversationsStore.findMessageIndex(currentMessageId);
+ const uiUpdate: Partial<DatabaseMessage> = {
+ content,
+ reasoningContent: reasoningContent || undefined,
+ toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
+ };
+
+ if (timings) uiUpdate.timings = timings;
+
+ if (resolvedModel) uiUpdate.model = resolvedModel;
+
+ // touch the active ui array and node pointer only when this conversation
+ // is displayed; otherwise persist the node move straight to the db so a
+ // foreign conv's currNode stays untouched
if (conversationsStore.activeConversation?.id === convId) {
- const idx = conversationsStore.findMessageIndex(messageId);
- if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
+ conversationsStore.updateMessageAtIndex(idx, uiUpdate);
+ await conversationsStore.updateCurrentNode(currentMessageId);
+ } else {
+ await DatabaseService.updateCurrentNode(convId, currentMessageId);
}
- await DatabaseService.updateMessage(messageId, updates);
},
- createAssistantMessage: async () => {
- // Reset streaming state for new message
- streamedContent = '';
- streamedReasoningContent = '';
+ onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => {
+ if (!extras.length) return;
- const msg = await DatabaseService.createMessageBranch(
- {
- convId,
- type: MessageType.TEXT,
- role: MessageRole.ASSISTANT,
- content: '',
- timestamp: Date.now(),
- toolCalls: '',
- children: [],
- model: resolvedModel
- },
- lastCreatedInFlow
- );
- if (conversationsStore.activeConversation?.id === convId) {
- conversationsStore.addMessageToActive(msg);
+ const idx = conversationsStore.findMessageIndex(messageId);
+
+ if (idx === -1) return;
+
+ const msg = conversationsStore.activeMessages[idx];
+ const updatedExtras = [...(msg.extra || []), ...extras];
+
+ conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
+ DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
+ },
+ onChunk: (chunk: string) => {
+ streamedContent += chunk;
+ updateStreamingUI();
+ this.setChatReasoning(convId, false);
+ },
+ onCompletionId: (id: string) => recordCompletionId(id),
+ onError: async (error: Error) => {
+ this.setStreamingActive(false);
+
+ if (isAbortError(error)) {
+ cleanupStreamingState();
+ // If aborted with a pending message (e.g. "Send immediately"), re-send it
+ const pending = this.consumePendingMessage(convId);
+
+ if (pending) {
+ this.sendMessage(pending.content, pending.extras);
+ }
+
+ return;
}
- currentMessageId = msg.id;
- lastCreatedInFlow = msg.id;
- return msg;
+
+ console.error('Streaming error:', error);
+ // keep whatever was streamed so far, the message stays in memory and in DB
+ await this.savePartialResponseIfNeeded(convId);
+ cleanupStreamingState();
+ this.clearPendingMessage(convId);
+
+ const contextInfo = (
+ error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
+ ).contextInfo;
+
+ this.showErrorDialog({
+ contextInfo,
+ message: error.message,
+ type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
+ });
+
+ if (onError) onError(error);
},
onFlowComplete: (finalTimings?: ChatMessageTimings) => {
if (finalTimings) {
cleanupStreamingState();
if (onComplete) onComplete(streamedContent);
+
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
+
// Pre-encode conversation in KV cache for faster next turn
if (config().preEncodeConversation) {
this.triggerPreEncode(
);
}
},
- onError: async (error: Error) => {
- this.setStreamingActive(false);
- if (isAbortError(error)) {
- cleanupStreamingState();
- // If aborted with a pending message (e.g. "Send immediately"), re-send it
- const pending = this.consumePendingMessage(convId);
- if (pending) {
- this.sendMessage(pending.content, pending.extras);
- }
- return;
- }
- console.error('Streaming error:', error);
- // keep whatever was streamed so far, the message stays in memory and in DB
- await this.savePartialResponseIfNeeded(convId);
- cleanupStreamingState();
- this.clearPendingMessage(convId);
+ onModel: (modelName: string) => recordModel(modelName),
+ onReasoningChunk: (chunk: string) => {
+ streamedReasoningContent += chunk;
+ // mark streaming state so a stop mid-thinking can persist the partial reasoning
+ this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
+ const idx = conversationsStore.findMessageIndex(currentMessageId);
- const contextInfo = (
- error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
- ).contextInfo;
- this.showErrorDialog({
- type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER,
- message: error.message,
- contextInfo
+ conversationsStore.updateMessageAtIndex(idx, {
+ reasoningContent: streamedReasoningContent
});
- if (onError) onError(error);
+ this.setChatReasoning(convId, true);
+ },
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+
+ this.updateProcessingStateFromTimings(
+ {
+ cache_n: timings?.cache_n || 0,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ prompt_ms: timings?.prompt_ms,
+ prompt_n: timings?.prompt_n || 0,
+ prompt_progress: promptProgress
+ },
+ convId
+ );
+ },
+ onToolCallsStreaming: (toolCalls) => {
+ const idx = conversationsStore.findMessageIndex(currentMessageId);
+
+ conversationsStore.updateMessageAtIndex(idx, {
+ toolCalls: JSON.stringify(toolCalls)
+ });
+ },
+ onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
+ // Update the first assistant message with cumulative agentic timings
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+
+ conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
+ },
+ updateToolResultMessage: async (
+ messageId: string,
+ content: string,
+ extras?: DatabaseMessageExtra[]
+ ) => {
+ // Persist latest content + merged extras; mirror into the active
+ // store so the chat view sees live updates for streaming tools
+ // (e.g. exec_shell_command). The existing tool message node
+ // pointer stays put - the renderer is already scoped to it.
+ const updates: Partial<DatabaseMessage> = { content };
+
+ if (extras) {
+ const idx = conversationsStore.findMessageIndex(messageId);
+ const existing = idx >= 0 ? (conversationsStore.activeMessages[idx]?.extra ?? []) : [];
+ const merged = [...existing, ...extras];
+
+ updates.extra = merged;
+ }
+
+ if (conversationsStore.activeConversation?.id === convId) {
+ const idx = conversationsStore.findMessageIndex(messageId);
+
+ if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
+ }
+
+ await DatabaseService.updateMessage(messageId, updates);
}
};
-
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
{
const agenticResult = await agenticStore.runAgenticFlow({
+ callbacks: streamCallbacks,
conversationId: convId,
messages: allMessages,
options: {
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {})
},
- callbacks: streamCallbacks,
- signal: abortController.signal,
- perChatOverrides
+ perChatOverrides,
+ signal: abortController.signal
});
+
if (agenticResult.handled) {
// Generate LLM based title for new conversations after agentic flow completes
if (firstUserMessageContent) {
await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
}
+
// Check if there's a pending steering message to re-send
const pending = agenticStore.consumePendingSteeringMessage(convId);
+
if (pending) {
await this.sendMessage(pending.content, pending.extras);
}
+
return;
}
}
{
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}),
- stream: true,
onChunk: streamCallbacks.onChunk,
- onReasoningChunk: streamCallbacks.onReasoningChunk,
- onModel: streamCallbacks.onModel,
- onCompletionId: streamCallbacks.onCompletionId,
- onTimings: streamCallbacks.onTimings,
- onConnectionState: (state: StreamConnectionState) => {
- if (convId === conversationsStore.activeConversation?.id) {
- this.streamConnectionState = state;
- }
- },
onComplete: async (
finalContent?: string,
reasoningContent?: string,
const updateData: Record<string, unknown> = {
content,
reasoningContent: reasoning || undefined,
- toolCalls: toolCalls || '',
- timings
+ timings,
+ toolCalls: toolCalls || ''
};
+
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
+
await DatabaseService.updateMessage(currentMessageId, updateData);
const idx = conversationsStore.findMessageIndex(currentMessageId);
const uiUpdate: Partial<DatabaseMessage> = {
reasoningContent: reasoning || undefined,
toolCalls: toolCalls || ''
};
+
if (timings) uiUpdate.timings = timings;
+
if (resolvedModel) uiUpdate.model = resolvedModel;
+
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(currentMessageId);
cleanupStreamingState();
+
if (onComplete) await onComplete(content);
+
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
// Generate LLM based title for new conversations (avoids stale reference
// Check if there's a pending message queued during streaming
const pending = this.consumePendingMessage(convId);
+
if (pending) {
await this.sendMessage(pending.content, pending.extras);
}
},
- onError: streamCallbacks.onError
+ onCompletionId: streamCallbacks.onCompletionId,
+ onConnectionState: (state: StreamConnectionState) => {
+ if (convId === conversationsStore.activeConversation?.id) {
+ this.streamConnectionState = state;
+ }
+ },
+ onError: streamCallbacks.onError,
+ onModel: streamCallbacks.onModel,
+ onReasoningChunk: streamCallbacks.onReasoningChunk,
+ onTimings: streamCallbacks.onTimings,
+ stream: true
},
convId,
abortController.signal
async stopGeneration(): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
+
await this.stopGenerationForChat(activeConv.id);
}
async stopGenerationForChat(convId: string): Promise<void> {
// captured when the session started, not the live dropdown
const streamStateForStop = this.chatStreamingStates.get(convId);
const modelForStop = streamStateForStop?.model ?? ChatService.getStreamState(convId)?.model;
+
void ChatService.cancelServerStream(convId, modelForStop);
// an explicit stop leaves nothing to resume and kills a pending resume retry
ChatService.clearStreamState(convId);
const retryTimer = this.resumeRetryTimers.get(convId);
+
if (retryTimer !== undefined) {
clearTimeout(retryTimer);
this.resumeRetryTimers.delete(convId);
}
+
this.resumePendingConvs.delete(convId);
this.abortRequest(convId);
this.setChatLoading(convId, false);
configValue.titleGenerationPrompt.trim()
? configValue.titleGenerationPrompt
: TITLE_GENERATION.DEFAULT_PROMPT;
-
const titlePrompt = titlePromptTemplate
.replace('{{USER}}', String(userContent || ''))
.replace('{{ASSISTANT}}', String(assistantContent || ''));
-
const titleMessage: ApiChatMessageData = {
- role: MessageRole.USER,
- content: titlePrompt
+ content: titlePrompt,
+ role: MessageRole.USER
};
-
const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel);
if (!titleResponse) {
}
let cleanTitle = titleResponse.trim();
+
cleanTitle = cleanTitle
.replace(TITLE_GENERATION.PREFIX_PATTERN, '')
.replace(TITLE_GENERATION.QUOTE_PATTERN, '')
.trim();
+
if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) {
const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);
+
cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK;
}
+
if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) {
await conversationsStore.updateConversationName(convId, cleanTitle);
}
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
const conversationId = convId || conversationsStore.activeConversation?.id;
+
if (!conversationId) return;
+
const streamingState = this.getChatStreaming(conversationId);
+
if (!streamingState) return;
+
const messages =
conversationId === conversationsStore.activeConversation?.id
? conversationsStore.activeMessages
: await conversationsStore.getConversationMessages(conversationId);
+
if (!messages.length) return;
+
const lastMessage = messages[messages.length - 1];
+
if (lastMessage?.role !== MessageRole.ASSISTANT) return;
const partialContent = streamingState.response;
} = {
toolCalls: ''
};
+
if (partialContent.trim()) updateData.content = partialContent;
+
if (partialReasoning.trim()) updateData.reasoningContent = partialReasoning;
+
const lastKnownState = this.getProcessingState(conversationId);
+
if (lastKnownState) {
updateData.timings = {
- prompt_n: lastKnownState.promptTokens || 0,
- prompt_ms: lastKnownState.promptMs,
- predicted_n: lastKnownState.tokensDecoded || 0,
cache_n: lastKnownState.cacheTokens || 0,
predicted_ms:
lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
- : undefined
+ : undefined,
+ predicted_n: lastKnownState.tokensDecoded || 0,
+ prompt_ms: lastKnownState.promptMs,
+ prompt_n: lastKnownState.promptTokens || 0
};
}
+
await DatabaseService.updateMessage(lastMessage.id, updateData);
lastMessage.content = partialContent;
// mirror the drop into the in-memory message so the next request sent via
// sendMessage (queued pending, Send immediately, or manual follow-up) reads
// the cleared value, not whatever the streaming widget had been showing
lastMessage.toolCalls = '';
+
if (updateData.timings) lastMessage.timings = updateData.timings;
} catch (error) {
lastMessage.content = partialContent;
async updateMessage(messageId: string, newContent: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
+
if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration();
+
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
+
if (!result) return;
- const { message: messageToUpdate, index: messageIndex } = result;
+
+ const { index: messageIndex, message: messageToUpdate } = result;
const originalContent = messageToUpdate.content;
+
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
+
conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseService.updateMessage(messageId, { content: newContent });
+
if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationName(
activeConv.id,
generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
);
+
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
+
if (messagesToRemove.length > 0)
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
+
conversationsStore.sliceActiveMessages(messageIndex + 1);
conversationsStore.updateConversationTimestamp();
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
const assistantMessage = await this.createAssistantMessage();
+
conversationsStore.addMessageToActive(assistantMessage);
await conversationsStore.updateCurrentNode(assistantMessage.id);
await this.streamChatCompletion(
async regenerateMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+
this.cancelPreEncode();
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
+
if (!result) return;
+
const { index: messageIndex } = result;
+
try {
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
+
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
conversationsStore.sliceActiveMessages(messageIndex);
conversationsStore.updateConversationTimestamp();
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
: undefined;
const assistantMessage = await this.createAssistantMessage(parentMessageId);
+
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
+
this.setChatLoading(activeConv?.id || '', false);
}
}
async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+
this.cancelPreEncode();
try {
const idx = conversationsStore.findMessageIndex(messageId);
+
if (idx === -1) return;
+
const msg = conversationsStore.activeMessages[idx];
+
if (msg.role !== MessageRole.ASSISTANT) return;
+
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const parentMessage = findMessageById(allMessages, msg.parent);
+
if (!parentMessage) return;
+
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: '',
convId: msg.convId,
- type: msg.type,
- timestamp: Date.now(),
+ model: null,
role: msg.role,
- content: '',
+ timestamp: Date.now(),
toolCalls: '',
- children: [],
- model: null
+ type: msg.type
},
parentMessage.id
);
+
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
false
) as DatabaseMessage[];
const modelToUse = modelOverride || msg.model || undefined;
+
await this.streamChatCompletion(
conversationPath,
newAssistantMessage,
} catch (error) {
if (!isAbortError(error))
console.error('Failed to regenerate message with branching:', error);
+
this.setChatLoading(activeConv?.id || '', false);
}
}
messageTypes: string[];
}> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv)
- return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
+ return { assistantMessages: 0, messageTypes: [], totalCount: 0, userMessages: 0 };
+
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
// For system messages, don't count descendants as they will be preserved (reparented to root)
if (messageToDelete?.role === MessageRole.SYSTEM) {
const messagesToDelete = allMessages.filter((m) => m.id === messageId);
- let userMessages = 0,
- assistantMessages = 0;
+
+ let assistantMessages = 0,
+ userMessages = 0;
+
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
+
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
+
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
- return { totalCount: 1, userMessages, assistantMessages, messageTypes };
+ return { assistantMessages, messageTypes, totalCount: 1, userMessages };
}
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
- let userMessages = 0,
- assistantMessages = 0;
+
+ let assistantMessages = 0,
+ userMessages = 0;
+
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
+
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
+
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
- return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes };
+ return { assistantMessages, messageTypes, totalCount: allToDelete.length, userMessages };
}
async deleteMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
+
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
*/
private async continueAsNextAgenticTurn(anchorIndex: number): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
+
const anchor = conversationsStore.activeMessages[anchorIndex];
+
if (!anchor) return;
+
this.cancelPreEncode();
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const anchorMessage = findMessageById(allMessages, anchor.id);
+
if (!anchorMessage) {
this.setChatLoading(activeConv.id, false);
+
return;
}
+
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: '',
convId: activeConv.id,
- type: MessageType.TEXT,
- timestamp: Date.now(),
+ model: null,
role: MessageRole.ASSISTANT,
- content: '',
+ timestamp: Date.now(),
toolCalls: '',
- children: [],
- model: null
+ type: MessageType.TEXT
},
anchorMessage.id
);
+
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
anchorMessage.id,
false
) as DatabaseMessage[];
+
await this.streamChatCompletion(conversationPath, newAssistantMessage);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue agentic turn:', error);
+
this.setChatLoading(activeConv.id, false);
}
}
async continueAssistantMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
- const { message: msg, index: idx } = result;
-
+ const { index: idx, message: msg } = result;
// Decide which resume path applies. tool_calls without tool results can
// not be resumed mid sequence by continue_final_message, branch instead.
// tool_calls already paired with tool results need a fresh next turn,
// not a token level continuation of the target assistant.
const intent = classifyContinueIntent(conversationsStore.activeMessages, idx);
+
if (intent.kind === ContinueIntentKind.RERUN_TURN) {
return this.regenerateMessageWithBranching(messageId);
}
+
if (intent.kind === ContinueIntentKind.NEXT_TURN) {
return this.continueAsNextAgenticTurn(intent.truncateAfter);
}
if (!dbMessage) {
this.setChatLoading(activeConv.id, false);
+
return;
}
content: fullContent
});
};
-
const abortController = this.getOrCreateAbortController(msg.convId);
await ChatService.sendMessage(
{
...this.getApiOptions(),
continueFinalMessage: true,
- onConnectionState: (state: StreamConnectionState) => {
- if (msg.convId === conversationsStore.activeConversation?.id) {
- this.streamConnectionState = state;
- }
- },
onChunk: (chunk: string) => {
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
this.setChatReasoning(msg.convId, false);
},
- onCompletionId: (id: string) => {
- if (!id) return;
- // refresh the message id so a later skip targets the live slot after a continue
- conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
- completionId: id
- });
- DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
- },
- onReasoningChunk: (chunk: string) => {
- appendedReasoning += chunk;
- hasReceivedContent = true;
- // mark streaming state so a stop mid-thinking can persist the partial reasoning
- this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id);
- conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
- reasoningContent: originalReasoning + appendedReasoning
- });
- this.setChatReasoning(msg.convId, true);
- },
- onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
- const tokensPerSecond =
- timings?.predicted_ms && timings?.predicted_n
- ? (timings.predicted_n / timings.predicted_ms) * 1000
- : 0;
- this.updateProcessingStateFromTimings(
- {
- prompt_n: timings?.prompt_n || 0,
- prompt_ms: timings?.prompt_ms,
- predicted_n: timings?.predicted_n || 0,
- predicted_per_second: tokensPerSecond,
- cache_n: timings?.cache_n || 0,
- prompt_progress: promptProgress
- },
- msg.convId
- );
- },
onComplete: async (
finalContent?: string,
reasoningContent?: string,
this.clearChatStreaming(msg.convId);
this.setProcessingState(msg.convId, null);
},
+ onCompletionId: (id: string) => {
+ if (!id) return;
+
+ // refresh the message id so a later skip targets the live slot after a continue
+ conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
+ completionId: id
+ });
+ DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
+ },
+ onConnectionState: (state: StreamConnectionState) => {
+ if (msg.convId === conversationsStore.activeConversation?.id) {
+ this.streamConnectionState = state;
+ }
+ },
onError: async (error: Error) => {
if (isAbortError(error)) {
if (hasReceivedContent && appendedContent) {
this.clearChatStreaming(msg.convId);
this.setProcessingState(msg.convId, null);
this.showErrorDialog({
- type:
- error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER,
- message: error.message
+ message: error.message,
+ type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
+ });
+ },
+ onReasoningChunk: (chunk: string) => {
+ appendedReasoning += chunk;
+ hasReceivedContent = true;
+ // mark streaming state so a stop mid-thinking can persist the partial reasoning
+ this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id);
+ conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
+ reasoningContent: originalReasoning + appendedReasoning
});
+ this.setChatReasoning(msg.convId, true);
+ },
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+
+ this.updateProcessingStateFromTimings(
+ {
+ cache_n: timings?.cache_n || 0,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ prompt_ms: timings?.prompt_ms,
+ prompt_n: timings?.prompt_n || 0,
+ prompt_progress: promptProgress
+ },
+ msg.convId
+ );
}
},
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue message:', error);
+
if (activeConv) this.setChatLoading(activeConv.id, false);
}
}
shouldBranch: boolean
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
+
if (!result) return;
- const { message: msg, index: idx } = result;
+ const { index: idx, message: msg } = result;
try {
if (shouldBranch) {
const newMessage = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: newContent,
convId: msg.convId,
- type: msg.type,
- timestamp: Date.now(),
+ model: msg.model,
role: msg.role,
- content: newContent,
+ timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
- children: [],
- model: msg.model
+ type: msg.type
},
msg.parent!
);
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
+
if (!result) return;
- const { message: msg, index: idx } = result;
+ const { index: idx, message: msg } = result;
+
try {
const updateData: Partial<DatabaseMessage> = { content: newContent };
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+
let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
+
if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
+
if (!result) return;
- const { message: msg, index: idx } = result;
+
+ const { index: idx, message: msg } = result;
+
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
// No responses after this message — update in place instead of branching
const updates: Partial<DatabaseMessage> = {
content: newContent,
- timestamp: Date.now(),
- extra: extrasToUse
+ extra: extrasToUse,
+ timestamp: Date.now()
};
+
await DatabaseService.updateMessage(msg.id, updates);
conversationsStore.updateMessageAtIndex(idx, updates);
messageIdForResponse = msg.id;
} else {
// Has children — create a new branch as sibling
const parentId = msg.parent || rootMessage?.id;
+
if (!parentId) return;
+
const newMessage = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: newContent,
convId: msg.convId,
- type: msg.type,
- timestamp: Date.now(),
+ extra: extrasToUse,
+ model: msg.model,
role: msg.role,
- content: newContent,
+ timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
- children: [],
- extra: extrasToUse,
- model: msg.model
+ type: msg.type
},
parentId
);
+
await conversationsStore.updateCurrentNode(newMessage.id);
messageIdForResponse = newMessage.id;
}
conversationsStore.updateConversationTimestamp();
+
if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationName(
activeConv.id,
generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
);
+
await conversationsStore.refreshActiveMessages();
+
if (msg.role === MessageRole.USER)
await this.generateResponseForMessage(messageIdForResponse);
} catch (error) {
private async generateResponseForMessage(userMessageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
+
if (!activeConv) return;
this.showErrorDialog(null);
) as DatabaseMessage[];
const assistantMessage = await DatabaseService.createMessageBranch(
{
+ children: [],
+ content: '',
convId: activeConv.id,
- type: MessageType.TEXT,
- timestamp: Date.now(),
+ model: null,
role: MessageRole.ASSISTANT,
- content: '',
+ timestamp: Date.now(),
toolCalls: '',
- children: [],
- model: null
+ type: MessageType.TEXT
},
userMessageId
);
if (processingState === null) {
console.warn('Failed to parse timing data - skipping update');
+
return;
}
const targetId = conversationId || this.activeConversationId;
+
if (targetId) {
this.setProcessingState(targetId, processingState);
}
}
private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
- const promptTokens = (timingData.prompt_n as number) || 0,
- promptMs = (timingData.prompt_ms as number) || undefined,
+ const cacheTokens = (timingData.cache_n as number) || 0,
predictedTokens = (timingData.predicted_n as number) || 0,
- tokensPerSecond = (timingData.predicted_per_second as number) || 0,
- cacheTokens = (timingData.cache_n as number) || 0;
+ promptMs = (timingData.prompt_ms as number) || undefined,
+ promptTokens = (timingData.prompt_n as number) || 0,
+ tokensPerSecond = (timingData.predicted_per_second as number) || 0;
const promptProgress = timingData.prompt_progress as
| { total: number; cache: number; processed: number; time_ms: number }
| undefined;
const progressPercent = promptProgress
? Math.round((progressActualDone / progressActualTotal) * 100)
: undefined;
+
return {
- status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
- tokensDecoded: predictedTokens,
- tokensRemaining: outputTokensMax - predictedTokens,
- contextUsed,
+ cacheTokens,
contextTotal,
- outputTokensUsed,
- outputTokensMax,
+ contextUsed,
hasNextToken: predictedTokens > 0,
- tokensPerSecond,
- temperature: currentConfig.temperature ?? 0.8,
- topP: currentConfig.top_p ?? 0.95,
- speculative: false,
+ outputTokensMax,
+ outputTokensUsed,
progressPercent,
+ promptMs,
promptProgress,
promptTokens,
- promptMs,
- cacheTokens
+ speculative: false,
+ status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
+ temperature: currentConfig.temperature ?? 0.8,
+ tokensDecoded: predictedTokens,
+ tokensPerSecond,
+ tokensRemaining: outputTokensMax - predictedTokens,
+ topP: currentConfig.top_p ?? 0.95
};
}
restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
+
if (message.role === MessageRole.ASSISTANT && message.timings) {
const restoredState = this.parseTimingData({
- prompt_n: message.timings.prompt_n || 0,
- prompt_ms: message.timings.prompt_ms,
+ cache_n: message.timings.cache_n || 0,
predicted_n: message.timings.predicted_n || 0,
predicted_per_second:
message.timings.predicted_n && message.timings.predicted_ms
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
: 0,
- cache_n: message.timings.cache_n || 0
+ prompt_ms: message.timings.prompt_ms,
+ prompt_n: message.timings.prompt_n || 0
});
+
if (restoredState) {
this.setProcessingState(conversationId, restoredState);
+
return;
}
}
getConversationModel(messages: DatabaseMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
+
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
}
+
return null;
}
if (isRouterMode()) {
const modelName = selectedModelName();
+
if (modelName) apiOptions.model = modelName;
}
// an explicit reasoning choice overrides the server default, DEFAULT sends nothing
const effort = conversationsStore.getReasoningEffort();
+
if (effort !== ReasoningEffort.DEFAULT) {
apiOptions.enableThinking = effort !== ReasoningEffort.OFF;
+
if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
}
try {
const allIdle = await ChatService.areAllSlotsIdle(model, signal);
+
if (!allIdle || signal.aborted) return;
const messagesWithAssistant: DatabaseMessage[] = [
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let lastPointerType = '';
-export const gaugePopup = $state({ open: false, centerX: 0, bottom: 0 });
+export const gaugePopup = $state({ bottom: 0, centerX: 0, open: false });
function openFrom(trigger: HTMLElement): void {
clearTimeout(closeTimer);
const frame = trigger.closest('form');
+
if (frame) {
const frameRect = frame.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
+
gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
}
+
gaugePopup.open = true;
}
export function gaugeTriggerClick(event: MouseEvent): void {
if (lastPointerType !== 'touch') return;
+
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' && event.key !== ' ') return;
+
event.preventDefault();
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
+
openFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
+
scheduleClose();
}
export function gaugeCardEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
+
clearTimeout(closeTimer);
}
export function gaugeCardLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
+
scheduleClose();
}
* @see DatabaseService in services/database.ts for IndexedDB operations
*/
-import { goto } from '$app/navigation';
import { browser } from '$app/environment';
-import { toast } from 'svelte-sonner';
-import { DatabaseService } from '$lib/services/database.service';
-import { MigrationService } from '$lib/services/migration.service';
-import { config } from '$lib/stores/settings.svelte';
-import { mcpStore } from '$lib/stores/mcp.svelte';
-import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
-import type { McpServerOverride } from '$lib/types/database';
-import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
-import {
- MessageRole,
- FileExtensionText,
- MimeTypeText,
- MimeTypeApplication,
- ReasoningEffort,
- SessionRecordType
-} from '$lib/enums';
+import { goto } from '$app/navigation';
import {
- ISO_DATE_TIME_SEPARATOR,
- ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
- ISO_TIMESTAMP_SLICE_LENGTH,
EXPORT_CONV_ID_TRIM_LENGTH,
- EXPORT_CONV_NONALNUM_REPLACEMENT,
EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH,
+ EXPORT_CONV_NONALNUM_REPLACEMENT,
+ ISO_DATE_TIME_SEPARATOR,
+ ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
ISO_TIME_SEPARATOR,
ISO_TIME_SEPARATOR_REPLACEMENT,
- NON_ALPHANUMERIC_REGEX,
+ ISO_TIMESTAMP_SLICE_LENGTH,
MULTIPLE_UNDERSCORE_REGEX,
- REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
NEWLINE,
+ NON_ALPHANUMERIC_REGEX,
+ REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
SESSION_HARNESS,
ZIP_MAGIC
} from '$lib/constants';
-
import { ROUTES } from '$lib/constants/routes';
+import {
+ FileExtensionText,
+ MessageRole,
+ MimeTypeApplication,
+ MimeTypeText,
+ ReasoningEffort,
+ SessionRecordType
+} from '$lib/enums';
+import { DatabaseService } from '$lib/services/database.service';
+import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
+import { mcpStore } from '$lib/stores/mcp.svelte';
+import { config } from '$lib/stores/settings.svelte';
+import type { McpServerOverride } from '$lib/types/database';
+import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
+import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+import { toast } from 'svelte-sonner';
export interface ConversationTreeItem {
conversation: DatabaseConversation;
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
private static loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
+
try {
const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
+
return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT;
} catch {
return ReasoningEffort.DEFAULT;
/** Persist reasoning effort default to localStorage */
private saveReasoningEffortDefaults(): void {
if (typeof globalThis.localStorage === 'undefined') return;
+
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
}
*/
init(): Promise<void> {
if (!browser) return Promise.resolve();
+
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
if (index !== -1) {
return this.activeMessages.splice(index, 1)[0];
}
+
return undefined;
}
*/
async loadConversations(): Promise<void> {
const conversations = await DatabaseService.getAllConversations();
+
this.conversations = conversations;
}
*/
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
-
// No MCP override list is seeded: getAllMcpServerOverrides resolves
// servers without a per-conversation override to `mcpServers[i].enabled`,
// and only explicit toggles are stored on the conversation.
// Working directory picked on the new-chat screen gets threaded in
// here too, then cleared so it doesn't bleed onto subsequent new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
- reasoningEffort: this.pendingReasoningEffort,
- cwd: this.pendingCwd ?? undefined
+ cwd: this.pendingCwd ?? undefined,
+ reasoningEffort: this.pendingReasoningEffort
});
+
this.pendingCwd = null;
this.conversations = [conversation, ...this.conversations];
conversation.currNode,
false
) as DatabaseMessage[];
+
this.activeMessages = filteredMessages;
} else {
const messages = await DatabaseService.getConversationMessages(convId);
+
this.activeMessages = messages;
}
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
+
return false;
}
}
// Collect all descendants recursively
const idsToRemove = new SvelteSet([convId]);
const queue = [convId];
+
while (queue.length > 0) {
const parentId = queue.pop()!;
+
for (const c of this.conversations) {
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
idsToRemove.add(c.id);
// Reparent direct children to deleted conv's parent (or promote to top-level)
const deletedConv = this.conversations.find((c) => c.id === convId);
const newParent = deletedConv?.forkedFromConversationId;
+
this.conversations = this.conversations
.filter((c) => c.id !== convId)
.map((c) =>
async deleteAll(): Promise<void> {
try {
const allConversations = await DatabaseService.getAllConversations();
+
await DatabaseService.bulkDeleteConversations(allConversations.map((c) => c.id));
this.clearActiveConversation();
// Collect all descendants recursively so the local cache stays consistent
// even when deleteWithForks is omitted.
const queue = [...convIds];
+
while (queue.length > 0) {
const parentId = queue.pop()!;
+
for (const c of this.conversations) {
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
idsToRemove.add(c.id);
try {
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
-
const activeId = this.activeConversation?.id;
+
if (activeId && updates.has(activeId)) {
this.activeConversation = {
...this.activeConversation!,
pinned: updates.get(activeId)!
};
}
+
for (let i = 0; i < this.conversations.length; i++) {
const newPinned = updates.get(this.conversations[i].id);
+
if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
}
try {
const fetched = await DatabaseService.getConversationsWithMessages(convIds);
-
const activeId = this.activeConversation?.id;
const overridden = fetched.get(activeId ?? '');
+
if (overridden && activeId) {
overridden.conv = { ...this.activeConversation! };
}
const exported = [...fetched.values()];
+
if (exported.length === 0) {
toast.error('No conversations to export');
+
return;
}
if (allMessages.length === 0) {
this.activeMessages = [];
+
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
-
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages = currentPath;
async toggleConversationPin(convId: string): Promise<boolean> {
try {
const newPinnedState = await DatabaseService.toggleConversationPin(convId);
-
const convIndex = this.conversations.findIndex((c) => c.id === convId);
if (convIndex !== -1) {
return newPinnedState;
} catch (error) {
console.error('Failed to toggle conversation pin:', error);
+
return false;
}
}
*/
updateConversationTimestamp(convId?: string): void {
const targetId = convId ?? this.activeConversation?.id;
+
if (!targetId) return;
const now = Date.now();
-
const chatIndex = this.conversations.findIndex((c) => c.id === targetId);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = now;
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
+
this.conversations = [updatedConv, ...this.conversations];
}
const currentFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
);
-
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
*/
#getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
+
if (!server) return undefined;
- return { serverId, enabled: server.enabled };
+
+ return { enabled: server.enabled, serverId };
}
/**
const override = this.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
+
if (override) return override;
+
return this.#getDefaultOverride(serverId);
}
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.activeConversation?.mcpServerOverrides;
+
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
- return { serverId: s.id, enabled: override?.enabled ?? s.enabled };
+
+ return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
}
*/
isMcpServerEnabledForChat(serverId: string): boolean {
const override = this.getMcpServerOverride(serverId);
+
return override?.enabled ?? false;
}
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
+
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
- serverId: o.serverId,
- enabled: o.enabled
+ enabled: o.enabled,
+ serverId: o.serverId
})
);
+
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
+
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
- newOverrides[existingIndex] = { serverId, enabled };
+ newOverrides[existingIndex] = { enabled, serverId };
} else {
- newOverrides = [...currentOverrides, { serverId, enabled }];
+ newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
};
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+
if (convIndex !== -1) {
this.conversations[convIndex].mcpServerOverrides =
newOverrides.length > 0 ? newOverrides : undefined;
*/
async toggleMcpServerForChat(serverId: string): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
+
await this.setMcpServerOverride(serverId, !currentEnabled);
}
if (this.activeConversation.reasoningEffort !== undefined) {
return this.activeConversation.reasoningEffort;
}
+
// conversations created before the tri-state store an explicit
// opt-out only as thinkingEnabled = false
if (this.activeConversation.thinkingEnabled === false) {
return ReasoningEffort.OFF;
}
}
+
return this.pendingReasoningEffort;
}
if (!this.activeConversation) {
this.pendingReasoningEffort = effort;
this.saveReasoningEffortDefaults();
+
return;
}
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+
if (convIndex !== -1) {
this.conversations[convIndex].reasoningEffort = effort;
}
// No chat yet - buffer for the first chat the user creates.
if (!this.activeConversation) {
this.pendingCwd = trimmed ?? null;
+
return;
}
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+
if (convIndex !== -1) {
this.conversations[convIndex].cwd = trimmed;
this.conversations = [...this.conversations];
}
+
this.pendingCwd = null;
}
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
-
const sanitizedName = conversationName
.replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT)
.replace(MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH);
-
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
-
const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH);
const formattedDate = iso
.replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? '';
+
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
*/
serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
-
const sessionLine = JSON.stringify({
- type: SessionRecordType.SESSION,
harness: SESSION_HARNESS,
+ type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
- return JSON.stringify({ type: SessionRecordType.MESSAGE, message: normalized });
+ return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
*/
parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
+
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
+
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
+
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
}
const message = record.message as DatabaseMessage;
+
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
+
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
+
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
+
sessions.push(...this.parseSessionsJsonl(strFromU8(entryBytes)));
}
+
return sessions;
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
+
if (Array.isArray(parsed)) {
return parsed;
}
+
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
+
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
if (!conversation) {
console.error('Invalid data: missing conversation');
+
return;
}
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
-
const jsonl = this.serializeSessionToJsonl(data);
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
+
this.triggerDownload(blob, downloadFilename);
}
downloadConversationsArchive(data: ExportedConversation[]): void {
if (data.length === 0) {
console.error('Invalid data: no conversations to export');
+
return;
}
// Disambiguate any duplicate filenames within the archive.
let entryName = baseName;
let suffix = 1;
+
while (usedNames.has(entryName)) {
entryName = baseName.replace(
new RegExp(`${FileExtensionText.JSONL}$`),
}
const archiveName = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
-
const zipped = zipSync(files);
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
+
this.triggerDownload(blob, archiveName);
}
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+
a.href = url;
a.download = filename;
document.body.appendChild(a);
data: ExportedConversations
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const result = await DatabaseService.importConversations(data);
+
await this.loadConversations();
+
return result;
}
}
// Pinned conversations first, then by lastModified descending
const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
if (a.pinned && !b.pinned) return -1;
+
if (!a.pinned && b.pinned) return 1;
+
return b.lastModified - a.lastModified;
};
result.push({ conversation: conv, depth });
const children = childrenByParent.get(conv.id);
+
if (children) {
children.sort(comparePinnedThenRecent);
}
const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
+
for (const root of roots) {
walk(root, 0);
}
const SERVER_DEFAULT: DeviceContext = {
isIOSDevice: false,
isIOSSafari: false,
- isWKWebView: false,
- isStandalone: false
+ isStandalone: false,
+ isWKWebView: false
};
function detect(): DeviceContext {
const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0;
-
const isIOSDevice = UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
-
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
// token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
const isIOSSafari = isIOSDevice && hasSafariToken;
const isWKWebView = isIOSDevice && !hasSafariToken;
-
// navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+).
const isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
- return { isIOSDevice, isIOSSafari, isWKWebView, isStandalone };
+ return { isIOSDevice, isIOSSafari, isStandalone, isWKWebView };
}
export const device = $state<DeviceContext>(detect());
getDraftMessage(chatId: string | undefined): DraftMessage {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
- return this.drafts.get(key) ?? { message: '', files: [] };
+
+ return this.drafts.get(key) ?? { files: [], message: '' };
}
saveDraftMessage(chatId: string | undefined, message: string, files: ChatUploadedFile[]): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
+
if (message || files.length > 0) {
- this.drafts.set(key, { message, files: [...files] });
+ this.drafts.set(key, { files: [...files], message });
} else {
this.drafts.delete(key);
}
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
+
this.drafts.delete(key);
}
}
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
*/
-import { SvelteMap } from 'svelte/reactivity';
-import { AttachmentType } from '$lib/enums';
import {
+ BINARY_CONTENT_LABEL,
MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
MCP_RESOURCE_CACHE_MAX_ENTRIES,
MCP_RESOURCE_CACHE_TTL_MS,
NEWLINE,
- RESOURCE_UNKNOWN_TYPE,
- BINARY_CONTENT_LABEL
+ RESOURCE_UNKNOWN_TYPE
} from '$lib/constants';
-import { normalizeResourceUri } from '$lib/utils';
+import { AttachmentType } from '$lib/enums';
import type {
+ DatabaseMessageExtraMcpResource,
+ MCPCachedResource,
MCPResource,
- MCPResourceTemplate,
+ MCPResourceAttachment,
MCPResourceContent,
MCPResourceInfo,
- MCPResourceTemplateInfo,
- MCPCachedResource,
- MCPResourceAttachment,
MCPResourceSubscription,
- MCPServerResources,
- DatabaseMessageExtraMcpResource
+ MCPResourceTemplate,
+ MCPResourceTemplateInfo,
+ MCPServerResources
} from '$lib/types';
+import { normalizeResourceUri } from '$lib/utils';
+import { SvelteMap } from 'svelte/reactivity';
function generateAttachmentId(): string {
return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
get totalResourceCount(): number {
let count = 0;
+
for (const serverRes of this._serverResources.values()) {
count += serverRes.resources.length;
}
get totalTemplateCount(): number {
let count = 0;
+
for (const serverRes of this._serverResources.values()) {
count += serverRes.templates.length;
}
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
- serverName,
- resources,
- templates,
+ error: undefined,
lastFetched: new Date(),
loading: false,
- error: undefined
+ resources,
+ serverName,
+ templates
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
*/
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
+
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
- serverName,
- resources: [],
- templates: [],
+ error: undefined,
loading,
- error: undefined
+ resources: [],
+ serverName,
+ templates: []
});
}
}
const existing = this._serverResources.get(serverName);
if (existing) {
- this._serverResources.set(serverName, { ...existing, loading: false, error });
+ this._serverResources.set(serverName, { ...existing, error, loading: false });
} else {
this._serverResources.set(serverName, {
- serverName,
- resources: [],
- templates: [],
+ error,
loading: false,
- error
+ resources: [],
+ serverName,
+ templates: []
});
}
}
for (const [serverName, serverRes] of this._serverResources) {
for (const resource of serverRes.resources) {
result.push({
- uri: resource.uri,
- name: resource.name,
- title: resource.title,
+ annotations: resource.annotations,
description: resource.description,
+ icons: resource.icons,
mimeType: resource.mimeType,
+ name: resource.name,
serverName,
- annotations: resource.annotations,
- icons: resource.icons
+ title: resource.title,
+ uri: resource.uri
});
}
}
for (const [serverName, serverRes] of this._serverResources) {
for (const template of serverRes.templates) {
result.push({
- uriTemplate: template.uriTemplate,
- name: template.name,
- title: template.title,
+ annotations: template.annotations,
description: template.description,
+ icons: template.icons,
mimeType: template.mimeType,
+ name: template.name,
serverName,
- annotations: template.annotations,
- icons: template.icons
+ title: template.title,
+ uriTemplate: template.uriTemplate
});
}
}
}
this._cachedResources.set(resource.uri, {
- resource,
content,
fetchedAt: new Date(),
+ resource,
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
*/
getCachedContent(uri: string): MCPCachedResource | undefined {
const cached = this._cachedResources.get(uri);
+
if (!cached) return undefined;
// Check if cache is still valid
*/
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
- uri,
serverName,
- subscribedAt: new Date()
+ subscribedAt: new Date(),
+ uri
});
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
+
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
+
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
// Update subscription last update time
const sub = this._subscriptions.get(uri);
+
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
handleResourcesListChanged(serverName: string): void {
// Mark server resources as needing refresh
const existing = this._serverResources.get(serverName);
+
if (existing) {
this._serverResources.set(serverName, {
...existing,
lastFetched: undefined // Mark as stale
});
}
+
console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`);
}
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
- resource,
- loading: true
+ loading: true,
+ resource
};
this._attachments = [...this._attachments, attachment];
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
- att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att
+ att.id === attachmentId ? { ...att, content, error: undefined, loading: false } : att
);
}
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
- att.id === attachmentId ? { ...att, loading: false, error } : att
+ att.id === attachmentId ? { ...att, error, loading: false } : att
);
}
if (resource) {
return {
- uri: resource.uri,
- name: resource.name,
- title: resource.title,
+ annotations: resource.annotations,
description: resource.description,
+ icons: resource.icons,
mimeType: resource.mimeType,
+ name: resource.name,
serverName,
- annotations: resource.annotations,
- icons: resource.icons
+ title: resource.title,
+ uri: resource.uri
};
}
}
for (const attachment of this._attachments) {
if (attachment.error) continue;
+
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
for (const attachment of this._attachments) {
if (attachment.error) continue;
+
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
if (contentParts.length > 0) {
extras.push({
- type: AttachmentType.MCP_RESOURCE,
+ content: contentParts.join(NEWLINE),
+ mimeType: attachment.resource.mimeType,
name: resourceName,
- uri: attachment.resource.uri,
serverName: attachment.resource.serverName,
- content: contentParts.join(NEWLINE),
- mimeType: attachment.resource.mimeType
+ type: AttachmentType.MCP_RESOURCE,
+ uri: attachment.resource.uri
});
}
}
* @see MCPService in services/mcp.service.ts for protocol operations
*/
+import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import { browser } from '$app/environment';
import { SETTINGS_KEYS } from '$lib/constants';
-import { MCPService } from '$lib/services/mcp.service';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
-import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
-import { serverStore } from '$lib/stores/server.svelte';
-import { mode } from 'mode-watcher';
-import {
- parseMcpServerSettings,
- detectMcpTransportFromUrl,
- uuid,
- extractRootDomain
-} from '$lib/utils';
-import {
- MCPConnectionPhase,
- MCPLogLevel,
- HealthCheckStatus,
- MCPRefType,
- ColorMode,
- UrlProtocol
-} from '$lib/enums';
import {
DEFAULT_CACHE_TTL_MS,
DEFAULT_MCP_CONFIG,
EXPECTED_THEMED_ICON_PAIR_COUNT,
MCP_ALLOWED_ICON_MIME_TYPES,
- MCP_SERVER_ID_PREFIX,
+ MCP_RECONNECT_ATTEMPT_TIMEOUT_MS,
MCP_RECONNECT_BACKOFF_MULTIPLIER,
MCP_RECONNECT_INITIAL_DELAY,
MCP_RECONNECT_MAX_DELAY,
- MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
+ MCP_SERVER_ID_PREFIX
} from '$lib/constants';
+import {
+ ColorMode,
+ HealthCheckStatus,
+ MCPConnectionPhase,
+ MCPLogLevel,
+ MCPRefType,
+ UrlProtocol
+} from '$lib/enums';
+import { MCPService } from '$lib/services/mcp.service';
+import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
import type {
- MCPToolCall,
- ServerStatus,
- ToolExecutionResult,
- MCPClientConfig,
- MCPConnection,
- HealthCheckParams,
- ServerCapabilities,
ClientCapabilities,
+ GetPromptResult,
+ HealthCheckParams,
+ HealthCheckState,
MCPCapabilitiesInfo,
+ MCPClientConfig,
+ MCPConnection,
MCPConnectionLog,
MCPPromptInfo,
- GetPromptResult,
- Tool,
- HealthCheckState,
- MCPServerSettingsEntry,
- MCPServerDisplayInfo,
- MCPServerConfig,
- MCPResourceIcon,
MCPResourceAttachment,
- MCPResourceContent
+ MCPResourceContent,
+ MCPResourceIcon,
+ MCPServerConfig,
+ MCPServerDisplayInfo,
+ MCPServerSettingsEntry,
+ MCPToolCall,
+ ServerCapabilities,
+ ServerStatus,
+ Tool,
+ ToolExecutionResult
} from '$lib/types';
-import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
+import {
+ detectMcpTransportFromUrl,
+ extractRootDomain,
+ parseMcpServerSettings,
+ uuid
+} from '$lib/utils';
+import { mode } from 'mode-watcher';
class MCPStore {
private _isInitializing = $state(false);
}
let parsed: unknown;
+
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
+
if (!trimmed) {
return [];
}
} else {
parsed = rawServers;
}
+
if (!Array.isArray(parsed)) {
return [];
}
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
return {
- id: this.#generateServerId((entry as { id?: unknown })?.id, index),
- enabled: Boolean((entry as { enabled?: unknown })?.enabled),
- url,
- name: (entry as { name?: string })?.name,
displayName: (entry as { displayName?: string })?.displayName,
+ enabled: Boolean((entry as { enabled?: unknown })?.enabled),
headers: headers || undefined,
+ id: this.#generateServerId((entry as { id?: unknown })?.id, index),
+ name: (entry as { name?: string })?.name,
+ url,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
#requestTimeoutMs(): number {
const seconds =
Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
+
return Math.round(seconds * 1000);
}
}
let headers: Record<string, string> | undefined;
+
if (entry.headers) {
try {
const parsed = JSON.parse(entry.headers);
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
headers = parsed as Record<string, string>;
} catch {
}
return {
- url: entry.url,
- transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
- requestTimeoutMs: this.#requestTimeoutMs(),
headers,
+ requestTimeoutMs: this.#requestTimeoutMs(),
+ transport: detectMcpTransportFromUrl(entry.url),
+ url: entry.url,
useProxy: entry.useProxy
};
}
// server's own `enabled` flag so partial override lists are not all
// treated as disabled.
const override = perChatOverrides?.find((o) => o.serverId === server.id);
+
return override?.enabled ?? server.enabled;
}
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = this.#parseServerSettings(cfg.mcpServers);
+
if (!rawServers.length) {
return undefined;
}
for (const [index, entry] of rawServers.entries()) {
if (!this.#checkServerEnabled(entry, perChatOverrides)) continue;
+
const normalized = this.#buildServerConfig(entry);
+
if (normalized) servers[this.#generateServerId(entry.id, index)] = normalized;
}
}
return {
- protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
+ protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
requestTimeoutMs: this.#requestTimeoutMs(),
servers
};
clientCaps?: ClientCapabilities
): MCPCapabilitiesInfo {
return {
+ client: {
+ elicitation: clientCaps?.elicitation
+ ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
+ : undefined,
+ roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
+ sampling: !!clientCaps?.sampling,
+ tasks: !!clientCaps?.tasks
+ },
server: {
- tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined,
+ completions: !!serverCaps?.completions,
+ logging: !!serverCaps?.logging,
prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
resources: serverCaps?.resources
? {
- subscribe: serverCaps.resources.subscribe,
- listChanged: serverCaps.resources.listChanged
+ listChanged: serverCaps.resources.listChanged,
+ subscribe: serverCaps.resources.subscribe
}
: undefined,
- logging: !!serverCaps?.logging,
- completions: !!serverCaps?.completions,
- tasks: !!serverCaps?.tasks
- },
- client: {
- roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
- sampling: !!clientCaps?.sampling,
- elicitation: clientCaps?.elicitation
- ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
- : undefined,
- tasks: !!clientCaps?.tasks
+ tasks: !!serverCaps?.tasks,
+ tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined
}
};
}
get isEnabled(): boolean {
const mcpConfig = this.#buildMcpClientConfig(config());
+
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
clearHealthCheck(serverId: string): void {
const { [serverId]: _removed, ...rest } = this._healthChecks;
+
this._healthChecks = rest;
}
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
+
return server.name || server.url;
}
*/
getServerDisplayName(serverId: string): string {
const server = this.getServerById(serverId);
+
return server ? this.getServerLabel(server) : serverId;
}
const validIcons = icons.filter((icon) => {
if (!icon.src || !this.#isValidIconUri(icon.src)) return false;
+
if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false;
+
return true;
});
if (validIcons.length === 0) return null;
const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT;
-
// 1. Prefer icon explicitly matching the current color scheme
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
+
if (themedIcon) return themedIcon.src;
// 2. Handle universal icons (no theme specified)
*/
getServerFavicon(serverId: string): string | null {
const server = this.getServerById(serverId);
+
if (!server) {
return null;
}
const isDark = mode.current === ColorMode.DARK;
const healthState = this.getHealthCheckState(serverId);
+
if (healthState.status === HealthCheckStatus.SUCCESS && healthState.serverInfo?.icons) {
const mcpIconUrl = this.#getMcpIconUrl(healthState.serverInfo.icons, isDark);
try {
const url = new URL(serverUrl);
const rootDomain = extractRootDomain(url);
+
if (!rootDomain) return null;
const origin = `${url.protocol}//${rootDomain}`;
for (const path of candidates) {
const faviconUrl = `${origin}/${path}`;
+
if (this.#isValidIconUri(faviconUrl)) {
return faviconUrl;
}
): MCPServerSettingsEntry {
const servers = this.getServers();
const newServer: MCPServerSettingsEntry = {
- id: serverData.id || (uuid() ?? `server-${Date.now()}`),
- enabled: serverData.enabled,
- url: serverData.url.trim(),
- name: serverData.name,
displayName: serverData.displayName,
+ enabled: serverData.enabled,
headers: serverData.headers?.trim() || undefined,
+ id: serverData.id || (uuid() ?? `server-${Date.now()}`),
+ name: serverData.name,
+ url: serverData.url.trim(),
useProxy: serverData.useProxy
};
+
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
+
return newServer;
}
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
const servers = this.getServers();
+
settingsStore.updateConfig(
SETTINGS_KEYS.MCP_SERVERS,
JSON.stringify(
removeServer(id: string): void {
const servers = this.getServers();
+
settingsStore.updateConfig(
SETTINGS_KEYS.MCP_SERVERS,
JSON.stringify(servers.filter((s) => s.id !== id))
const mcpConfig = this.#buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
+
if (!signature) {
await this.shutdown();
return false;
}
+
if (this.isInitialized && this.configSignature === signature) {
return true;
}
}
if (this.connections.size > 0 || this.initPromise) await this.shutdown();
+
return this.initialize(signature, mcpConfig!);
}
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
- this.updateState({ isInitializing: true, error: null });
+ this.updateState({ error: null, isInitializing: true });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
- this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] });
+ this.updateState({ connectedServers: [], isInitializing: false, toolCount: 0 });
return false;
}
+
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
return this.initPromise;
listChangedHandlers
);
- return { name, connection };
+ return { connection, name };
})
);
+
if (this.configSignature !== signature) {
for (const result of results) {
if (result.status === 'fulfilled')
return false;
}
+
for (const result of results) {
if (result.status === 'fulfilled') {
- const { name, connection } = result.value;
+ const { connection, name } = result.value;
this.connections.set(name, connection);
console.warn(
`[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".`
);
+
this.toolsIndex.set(tool.name, name);
}
} else {
}
const successCount = this.connections.size;
+
if (successCount === 0 && serverEntries.length > 0) {
this.updateState({
- isInitializing: false,
+ connectedServers: [],
error: 'All MCP server connections failed',
- toolCount: 0,
- connectedServers: []
+ isInitializing: false,
+ toolCount: 0
});
this.initPromise = null;
}
this.updateState({
- isInitializing: false,
+ connectedServers: Array.from(this.connections.keys()),
error: null,
- toolCount: this.toolsIndex.size,
- connectedServers: Array.from(this.connections.keys())
+ isInitializing: false,
+ toolCount: this.toolsIndex.size
});
this.initPromise = null;
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
- tools: {
- onChanged: (error: Error | null, tools: Tool[] | null) => {
+ prompts: {
+ onChanged: (error: Error | null) => {
if (error) {
- console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
+ console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
+
return;
}
- this.handleToolsListChanged(serverName, tools ?? []);
}
},
- prompts: {
- onChanged: (error: Error | null) => {
+ tools: {
+ onChanged: (error: Error | null, tools: Tool[] | null) => {
if (error) {
- console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
+ console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
+
return;
}
+
+ this.handleToolsListChanged(serverName, tools ?? []);
}
}
};
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
+
if (!connection) {
return;
}
console.warn(
`[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".`
);
+
this.toolsIndex.set(tool.name, serverName);
}
this.updateState({ toolCount: this.toolsIndex.size });
*/
async releaseConnection(shutdownIfUnused = false): Promise<void> {
this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
+
if (shutdownIfUnused && this.activeFlowCount === 0) {
await this.shutdown();
}
this.serverConfigs.clear();
this.configSignature = null;
this.updateState({
- isInitializing: false,
+ connectedServers: [],
error: null,
- toolCount: 0,
- connectedServers: []
+ isInitializing: false,
+ toolCount: 0
});
}
*/
private async reconnectServer(serverName: string): Promise<void> {
const serverConfig = this.serverConfigs.get(serverName);
+
if (!serverConfig) {
throw new Error(`[MCPStore] No config found for ${serverName}, cannot reconnect`);
}
// Disconnect stale connection (clears old transport + session ID)
const oldConnection = this.connections.get(serverName);
+
if (oldConnection) {
await MCPService.disconnect(oldConnection).catch(console.warn);
this.connections.delete(serverName);
}
const serverConfig = this.serverConfigs.get(serverName);
+
if (!serverConfig) {
console.error(`[MCPStore] No config found for ${serverName}, cannot reconnect`);
},
listChangedHandlers
);
-
const connection = await Promise.race([connectPromise, timeoutPromise]);
// Replace old connection with new one
}
console.log(`[MCPStore][${serverName}] Reconnected successfully`);
+
break;
} catch (error) {
console.warn(`[MCPStore][${serverName}] Reconnection failed:`, error);
}
} finally {
this.reconnectingServers.delete(serverName);
+
// If the phase callback signalled a disconnect while this function held
// the guard, kick off a fresh reconnect now that the guard is released.
if (needsReconnect) {
*/
findServerForTool(toolName: string): string | undefined {
const fromIndex = this.toolsIndex.get(toolName);
+
if (fromIndex) return fromIndex;
for (const server of this.getServers()) {
const health = this._healthChecks[server.id];
+
if (!health || health.status !== HealthCheckStatus.SUCCESS) continue;
+
if (health.tools.some((tool) => tool.name === toolName)) {
return server.id;
}
*/
getServerFaviconForTool(toolName: string | undefined): string | null {
if (!toolName) return null;
+
const serverId = this.findServerForTool(toolName);
+
if (!serverId) return null;
+
return this.getServerFavicon(serverId);
}
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (!enabledServerIds.has(serverId)) continue;
+
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.prompts !== undefined
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
+
if (connection.serverCapabilities?.prompts) {
return true;
}
for (const prompt of prompts) {
results.push({
- name: prompt.name,
- description: prompt.description,
- title: prompt.title,
- serverName,
arguments: prompt.arguments?.map((arg) => ({
- name: arg.name,
description: arg.description,
+ name: arg.name,
required: arg.required
- }))
+ })),
+ description: prompt.description,
+ name: prompt.name,
+ serverName,
+ title: prompt.title
});
}
}
args?: Record<string, string>
): Promise<GetPromptResult> {
const connection = this.connections.get(serverName);
+
if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
return MCPService.getPrompt(connection, promptName, args);
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
const toolName = toolCall.function.name;
-
const serverName = this.toolsIndex.get(toolName);
+
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
const connection = this.connections.get(serverName);
+
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
const args = this.parseToolArguments(toolCall.function.arguments);
try {
- return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
+ return await MCPService.callTool(connection, { arguments: args, name: toolName }, signal);
} catch (error) {
// Session expired (server restarted) - reconnect and retry once
if (MCPService.isSessionExpiredError(error)) {
await this.reconnectServer(serverName);
const newConnection = this.connections.get(serverName);
+
if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`);
- return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal);
+ return MCPService.callTool(newConnection, { arguments: args, name: toolName }, signal);
}
throw error;
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const serverName = this.toolsIndex.get(toolName);
+
if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
+
const connection = this.connections.get(serverName);
+
if (!connection) throw new Error(`Server "${serverName}" is not connected`);
try {
- return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
+ return await MCPService.callTool(connection, { arguments: args, name: toolName }, signal);
} catch (error) {
if (MCPService.isSessionExpiredError(error)) {
await this.reconnectServer(serverName);
const newConnection = this.connections.get(serverName);
+
if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`);
- return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal);
+ return MCPService.callTool(newConnection, { arguments: args, name: toolName }, signal);
}
throw error;
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'string') {
const trimmed = args.trim();
+
if (trimmed === '') {
return {};
}
try {
const parsed = JSON.parse(trimmed);
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
throw new Error(
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
argumentValue: string
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
const connection = this.connections.get(serverName);
+
if (!connection) {
console.warn(`[MCPStore] Server "${serverName}" is not connected`);
+
return null;
}
+
if (!connection.serverCapabilities?.completions) {
return null;
}
return MCPService.complete(
connection,
- { type: MCPRefType.PROMPT, name: promptName },
+ { name: promptName, type: MCPRefType.PROMPT },
{ name: argumentName, value: argumentValue }
);
}
if (!connection) {
console.warn(`[MCPStore] Server "${serverName}" is not connected`);
+
return null;
}
try {
const parsed = JSON.parse(headersJson);
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
return parsed as Record<string, string>;
} catch {
}
const BATCH_SIZE = 5;
+
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
+
await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive)));
}
}
async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise<void> {
// Check if we already have an active connection for this server
const existingConnection = this.connections.get(server.id);
+
if (existingConnection) {
// Reuse existing connection - just refresh tools list
try {
existingConnection.serverCapabilities,
existingConnection.clientCapabilities
);
+
this.updateHealthCheck(server.id, {
+ capabilities,
+ connectionTimeMs: existingConnection.connectionTimeMs,
+ instructions: existingConnection.instructions,
+ logs: [],
+ protocolVersion: existingConnection.protocolVersion,
+ serverInfo: existingConnection.serverInfo,
status: HealthCheckStatus.SUCCESS,
tools: tools.map((tool) => ({
- name: tool.name,
description: tool.description,
+ name: tool.name,
title: tool.title
})),
- serverInfo: existingConnection.serverInfo,
- capabilities,
- transportType: existingConnection.transportType,
- protocolVersion: existingConnection.protocolVersion,
- instructions: existingConnection.instructions,
- connectionTimeMs: existingConnection.connectionTimeMs,
- logs: []
+ transportType: existingConnection.transportType
});
+
return;
} catch (error) {
console.warn(
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
+
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.updateHealthCheck(server.id, {
- status: HealthCheckStatus.ERROR,
+ logs: [],
message: 'Please enter a server URL first.',
- logs: []
+ status: HealthCheckStatus.ERROR
});
+
return;
}
this.updateHealthCheck(server.id, {
- status: HealthCheckStatus.CONNECTING,
+ logs: [],
phase: MCPConnectionPhase.TRANSPORT_CREATING,
- logs: []
+ status: HealthCheckStatus.CONNECTING
});
const timeoutMs = this.#requestTimeoutMs();
try {
const serverConfig: MCPServerConfig = {
- url: trimmedUrl,
- transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
- requestTimeoutMs: timeoutMs,
headers,
+ requestTimeoutMs: timeoutMs,
+ transport: detectMcpTransportFromUrl(trimmedUrl),
+ url: trimmedUrl,
useProxy: server.useProxy
};
currentPhase = phase;
logs.push(log);
this.updateHealthCheck(server.id, {
- status: HealthCheckStatus.CONNECTING,
+ logs: [...logs],
phase,
- logs: [...logs]
+ status: HealthCheckStatus.CONNECTING
});
// Handle WebSocket disconnection
}
}
);
-
const tools = connection.tools.map((tool) => ({
- name: tool.name,
description: tool.description,
+ name: tool.name,
title: tool.title
}));
-
const capabilities = this.#buildCapabilitiesInfo(
connection.serverCapabilities,
connection.clientCapabilities
);
this.updateHealthCheck(server.id, {
- status: HealthCheckStatus.SUCCESS,
- tools,
- serverInfo: connection.serverInfo,
capabilities,
- transportType: connection.transportType,
- protocolVersion: connection.protocolVersion,
- instructions: connection.instructions,
connectionTimeMs: connection.connectionTimeMs,
- logs
+ instructions: connection.instructions,
+ logs,
+ protocolVersion: connection.protocolVersion,
+ serverInfo: connection.serverInfo,
+ status: HealthCheckStatus.SUCCESS,
+ tools,
+ transportType: connection.transportType
});
// Promote to active connection or disconnect
if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) {
logs.push({
- timestamp: new Date(),
- phase: MCPConnectionPhase.ERROR,
+ level: MCPLogLevel.ERROR,
message: `Connection failed: ${message}`,
- level: MCPLogLevel.ERROR
+ phase: MCPConnectionPhase.ERROR,
+ timestamp: new Date()
});
}
this.updateHealthCheck(server.id, {
- status: HealthCheckStatus.ERROR,
+ logs,
message,
phase: currentPhase,
- logs
+ status: HealthCheckStatus.ERROR
});
}
}
`[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".`
);
}
+
this.toolsIndex.set(tool.name, serverId);
}
// Update state
this.updateState({
- toolCount: this.toolsIndex.size,
- connectedServers: Array.from(this.connections.keys())
+ connectedServers: Array.from(this.connections.keys()),
+ toolCount: this.toolsIndex.size
});
}
for (const [name, connection] of this.connections) {
statuses.push({
- name,
+ error: undefined,
isConnected: true,
- toolCount: connection.tools.length,
- error: undefined
+ name,
+ toolCount: connection.tools.length
});
}
for (const [serverName, connection] of this.connections) {
if (connection.instructions) {
results.push({
+ instructions: connection.instructions,
serverName,
- serverTitle: connection.serverInfo?.title || connection.serverInfo?.name,
- instructions: connection.instructions
+ serverTitle: connection.serverInfo?.title || connection.serverInfo?.name
});
}
}
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (state.status === HealthCheckStatus.SUCCESS && state.instructions) {
results.push({
+ instructions: state.instructions,
serverId,
- serverTitle: state.serverInfo?.title || state.serverInfo?.name,
- instructions: state.instructions
+ serverTitle: state.serverInfo?.title || state.serverInfo?.name
});
}
}
.map((s) => s.id)
);
}
+
if (enabledServerIds.size === 0) {
return false;
}
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (!enabledServerIds.has(serverId)) continue;
+
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
+
if (MCPService.supportsResources(connection)) {
return true;
}
// Check active connections
for (const [name, connection] of this.connections) {
if (!enabledServerIds.has(name)) continue;
+
if (MCPService.supportsResources(connection) && !servers.includes(name)) {
servers.push(name);
}
// Also check health check states for servers not yet connected
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (!enabledServerIds.has(serverId)) continue;
+
if (
!servers.includes(serverId) &&
state.status === HealthCheckStatus.SUCCESS &&
*/
async fetchAllResources(forceRefresh: boolean = false): Promise<void> {
const serversWithResources = this.getServersWithResources();
+
if (serversWithResources.length === 0) {
return;
}
if (!forceRefresh) {
const allServersCached = serversWithResources.every((serverName) => {
const serverRes = mcpResourceStore.getServerResources(serverName);
+
if (!serverRes || !serverRes.lastFetched) {
return false;
}
*/
async fetchServerResources(serverName: string): Promise<void> {
const connection = this.connections.get(serverName);
+
if (!connection) {
console.warn(`[MCPStore] No connection found for server: ${serverName}`);
+
return;
}
mcpResourceStore.setServerResources(serverName, resources, templates);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+
mcpResourceStore.setServerError(serverName, message);
console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error);
}
async readResource(uri: string): Promise<MCPResourceContent[] | null> {
// Check cache first
const cached = mcpResourceStore.getCachedContent(uri);
+
if (cached) {
return cached.content;
}
// Find which server has this resource
const serverName = mcpResourceStore.findServerForUri(uri);
+
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
}
const connection = this.connections.get(serverName);
+
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
*/
async subscribeToResource(uri: string): Promise<boolean> {
const serverName = mcpResourceStore.findServerForUri(uri);
+
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
}
const connection = this.connections.get(serverName);
+
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
*/
async unsubscribeFromResource(uri: string): Promise<boolean> {
const serverName = mcpResourceStore.findServerForUri(uri);
+
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
}
const connection = this.connections.get(serverName);
+
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
*/
async attachResource(uri: string): Promise<MCPResourceAttachment | null> {
const resourceInfo = mcpResourceStore.findResourceByUri(uri);
+
if (!resourceInfo) {
console.error(`[MCPStore] Resource not found: ${uri}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+
mcpResourceStore.updateAttachmentError(attachment.id, message);
}
*/
consumeResourceAttachmentsAsExtras(): DatabaseMessageExtraMcpResource[] {
const extras = mcpResourceStore.toMessageExtras();
+
if (extras.length > 0) {
mcpResourceStore.clearAttachments();
}
+
return extras;
}
}
import { base } from '$app/paths';
-import { SvelteMap, SvelteSet } from 'svelte/reactivity';
-import { toast } from 'svelte-sonner';
import {
- ServerModelStatus,
- ServerModelsSseEventType,
+ API_MODELS,
+ FAVORITE_MODELS_LOCALSTORAGE_KEY,
+ MODEL_PROPS_CACHE_MAX_ENTRIES,
+ MODEL_PROPS_CACHE_TTL_MS,
+ SSE_DATA_PREFIX,
+ SSE_LINE_SEPARATOR,
+ SSE_RECORD_SEPARATOR
+} from '$lib/constants';
+import {
+ FileTypeCategory,
ModelModality,
- FileTypeCategory
+ ServerModelsSseEventType,
+ ServerModelStatus
} from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
-import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
+import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
+import { getAuthHeaders, TTLCache } from '$lib/utils';
import {
detectThinkingSupport,
detectThinkingSupportWithReason
} from '$lib/utils/chat-template-thinking-detector';
-import { TTLCache, getAuthHeaders } from '$lib/utils';
-import {
- MODEL_PROPS_CACHE_TTL_MS,
- MODEL_PROPS_CACHE_MAX_ENTRIES,
- FAVORITE_MODELS_LOCALSTORAGE_KEY,
- API_MODELS,
- SSE_RECORD_SEPARATOR,
- SSE_LINE_SEPARATOR,
- SSE_DATA_PREFIX
-} from '$lib/constants';
-
-import { conversationsStore } from '$lib/stores/conversations.svelte';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+import { toast } from 'svelte-sonner';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes.
* TTL: 10 minutes — props don't change frequently.
*/
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
- ttlMs: MODEL_PROPS_CACHE_TTL_MS,
- maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES
+ maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES,
+ ttlMs: MODEL_PROPS_CACHE_TTL_MS
});
private modelPropsFetching = $state<Set<string>>(new Set());
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
+
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
}
if (isRouterMode()) return null;
const props = serverStore.props;
+
if (props?.model_alias) return props.model_alias;
+
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
+
return this.getModelContextSize(this.selectedModelName);
}
}
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
+
if (model?.modalities) {
return model.modalities;
}
const props = this.modelPropsCache.get(modelId);
+
if (props?.modalities) {
return this.buildModalities(props.modalities);
}
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
+
if (!modalities) return [];
const result: ModelModality[] = [];
+
if (modalities.vision) result.push(ModelModality.VISION);
+
if (modalities.audio) result.push(ModelModality.AUDIO);
+
if (modalities.video) result.push(ModelModality.VIDEO);
return result;
}
const modelId = this.selectedModelName;
+
if (!modelId) return false;
if (!this.modelPropsCache.get(modelId)) {
this.fetchModelProps(modelId);
}
+
const props = this.getModelProps(modelId);
+
return detectThinkingSupport(props?.chat_template ?? '');
}
}
const props = this.getModelProps(modelId);
+
return detectThinkingSupport(props?.chat_template ?? '');
}
}
const modelId = this.selectedModelName;
+
if (!modelId) {
- return { supported: false, reason: 'No model selected' };
+ return { reason: 'No model selected', supported: false };
}
if (!this.modelPropsCache.get(modelId)) {
this.fetchModelProps(modelId);
}
+
const props = this.getModelProps(modelId);
+
return detectThinkingSupportWithReason(props?.chat_template ?? '');
}
*/
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
+
if (this.models.length > 0 && !force) return;
this.inflightFetch = this.runFetch();
const modelId = details?.model || item.id;
return {
- id: item.id,
- name: this.toDisplayName(displayNameSource),
- model: modelId,
- description: details?.description,
+ aliases: item.aliases ?? [],
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
- modalities: this.buildArchitectureModalities(item.architecture),
+ description: details?.description,
details: details?.details,
+ id: item.id,
meta: item.meta ?? null,
+ modalities: this.buildArchitectureModalities(item.architecture),
+ model: modelId,
+ name: this.toDisplayName(displayNameSource),
parsedId: ModelsService.parseModelId(modelId),
- aliases: item.aliases ?? [],
tags: item.tags ?? []
};
});
try {
const response = await ModelsService.listRouter();
+
this.routerModels = response.data;
await this.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
+
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
const cached = this.modelPropsCache.get(modelId);
+
if (cached) return cached;
if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
try {
const props = await PropsService.fetchForModel(modelId);
+
this.modelPropsCache.set(modelId, props);
this.propsCacheVersion++;
+
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
+
return null;
} finally {
this.modelPropsFetching.delete(modelId);
/** Fetch modalities for all loaded models from /props endpoint. */
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.loadedModelIds;
+
if (loadedModelIds.length === 0) return;
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
this.models = this.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
+
if (modelIndex === -1) return model;
const props = results[modelIndex];
+
if (!props?.modalities) return model;
return { ...model, modalities: this.buildModalities(props.modalities) };
*/
async updateModelModalities(modelId: string): Promise<void> {
const props = await this.fetchModelProps(modelId);
+
if (!props?.modalities) return;
this.models = this.models.map((model) =>
*/
getModelFromLastAssistantResponse(): string | null {
const messages = conversationsStore.activeMessages;
+
if (!messages || messages.length === 0) return null;
for (let i = messages.length - 1; i >= 0; i--) {
*/
async selectModelFromLastAssistantResponse(): Promise<boolean> {
const lastModel = this.getModelFromLastAssistantResponse();
+
if (!lastModel || this.selectedModelName === lastModel) return false;
const matchingModel = this.models.find((option) => option.model === lastModel);
+
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
try {
await this.selectModelById(matchingModel.id);
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
+
return true;
} catch (error) {
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
+
return false;
}
}
if (this.selectedModelName) return;
const availableModels = this.getVisibleModels();
+
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
const lastModel = this.getModelFromLastAssistantResponse();
+
if (lastModel) {
const lastModelOption = availableModels.find((m) => m.model === lastModel);
+
if (lastModelOption) {
await this.selectModelById(lastModelOption.id);
+
if (this.isModelLoaded(lastModel)) {
await this.fetchModelProps(lastModel);
}
+
return;
}
}
// Try a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
+
if (loadedModel) {
await this.selectModelById(loadedModel.id);
await this.fetchModelProps(loadedModel.model);
+
return;
}
// Try loading a favorite model
const favorite = this.favoriteModelIds.values().next()?.value;
+
if (favorite) {
await this.selectModelById(favorite);
+
return;
}
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
+
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
+
if (!option) throw new Error('Selected model is not available');
this.updating = true;
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
+
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
*/
subscribeStatus(): void {
if (this.statusReaderActive) return;
+
if (!isRouterMode()) return;
this.statusReaderActive = true;
if (response.ok && response.body) {
const reader = response.body.getReader();
+
let buffer = '';
while (!signal.aborted) {
- const { value, done } = await reader.read();
+ const { done, value } = await reader.read();
+
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
+
while (boundary !== -1) {
this.handleStatusRecord(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
if (payload.length === 0) return;
let envelope: ApiModelsSseEvent;
+
try {
envelope = JSON.parse(payload);
} catch {
case ServerModelsSseEventType.MODEL_STATUS:
case ServerModelsSseEventType.STATUS_UPDATE:
this.applyModelStatus(event);
+
break;
case ServerModelsSseEventType.MODELS_RELOAD:
void this.fetchRouterModels();
+
break;
case ServerModelsSseEventType.MODEL_REMOVE:
this.removeRouterModel(event.model);
+
break;
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
break;
private applyModelStatus(event: ApiModelsSseEvent): void {
const model = event.model;
const data = event.data;
+
if (!model || !data?.status) return;
const status = data.status;
if (failed) {
this.rejectStatus(model, new Error(`Model failed: ${this.toDisplayName(model)}`));
+
return;
}
*/
private setRouterModelStatus(modelId: string, status: ServerModelStatus): void {
const idx = this.routerModels.findIndex((m) => m.id === modelId);
+
if (idx === -1) return;
const current = this.routerModels[idx];
+
if (current.status.value === status) return;
const next = [...this.routerModels];
+
next[idx] = { ...current, status: { ...current.status, value: status } };
this.routerModels = next;
}
*/
private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
return new Promise((resolve, reject) => {
- this.statusWaiters.set(modelId, { target, resolve, reject });
+ this.statusWaiters.set(modelId, { reject, resolve, target });
});
}
*/
private settleStatus(modelId: string, status: ServerModelStatus): void {
const waiter = this.statusWaiters.get(modelId);
+
if (waiter && waiter.target === status) {
this.statusWaiters.delete(modelId);
waiter.resolve();
*/
private rejectStatus(modelId: string, error: Error): void {
const waiter = this.statusWaiters.get(modelId);
+
if (waiter) {
this.statusWaiters.delete(modelId);
waiter.reject(error);
async loadModel(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) return;
+
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.subscribeStatus();
const reachedLoaded = this.waitForStatus(modelId, ServerModelStatus.LOADED);
+
reachedLoaded.catch(() => {});
try {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('load failed'));
this.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
+
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
async unloadModel(modelId: string): Promise<void> {
if (!this.isModelLoaded(modelId)) return;
+
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.subscribeStatus();
const reachedUnloaded = this.waitForStatus(modelId, ServerModelStatus.UNLOADED);
+
reachedUnloaded.catch(() => {});
try {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('unload failed'));
this.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
+
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
async ensureModelLoaded(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) return;
+
await this.loadModel(modelId);
}
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
+
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favorite models from local storage');
+
return new Set();
}
}
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
+
return candidate && candidate.trim().length > 0 ? candidate : id;
}
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
): ModelModalities {
return {
- vision: modalities.vision ?? false,
audio: modalities.audio ?? false,
- video: modalities.video ?? false
+ video: modalities.video ?? false,
+ vision: modalities.vision ?? false
};
}
const inputs = architecture.input_modalities;
return {
- vision: inputs.includes(FileTypeCategory.IMAGE),
audio: inputs.includes(FileTypeCategory.AUDIO),
- video: inputs.includes(FileTypeCategory.VIDEO)
+ video: inputs.includes(FileTypeCategory.VIDEO),
+ vision: inputs.includes(FileTypeCategory.IMAGE)
};
}
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
-
import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
constructor() {
try {
const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY);
+
if (stored) {
for (const name of JSON.parse(stored) as string[]) {
if (typeof name === 'string') this._tools.add(name);
try {
if (next === null || next === undefined) {
localStorage.removeItem(key);
+
return;
}
-import { PropsService } from '$lib/services/props.service';
import { ServerRole } from '$lib/enums';
+import { PropsService } from '$lib/services/props.service';
import { ApiError } from '$lib/utils/api-fetch';
const LOADING_RETRY_INTERVAL_MS = 1000;
if (this.fetchPromise) return this.fetchPromise;
this.clearRetryTimer();
+
if (!background) {
this.loading = true;
}
+
// Don't clear an existing "still loading" error before a retry -
// doing so would unmount/remount the error banner every second.
if (this.status !== 503) {
const fetchPromise = (async () => {
try {
const props = await PropsService.fetch();
+
this.props = props;
this.error = null;
this.status = null;
if (!background) {
this.loading = false;
}
+
this.fetchPromise = null;
}
})();
private scheduleRetry(): void {
if (this.retryTimer) return;
+
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.fetch({ background: true });
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
+
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
*/
import { browser } from '$app/environment';
-import { ColorMode } from '$lib/enums';
-import type { SettingsExportType } from '$lib/types';
-import { setMode } from 'mode-watcher';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
-import { isMobile } from '$lib/stores/viewport.svelte';
+import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
+import { isMobile } from '$lib/stores/viewport.svelte';
+import type { SettingsExportType } from '$lib/types';
import {
configToParameterRecord,
- normalizeFloatingPoint,
getConfigValue,
+ normalizeFloatingPoint,
setConfigValue
} from '$lib/utils';
+import { setMode } from 'mode-watcher';
class SettingsStore {
/**
const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
(key) => key in savedVal
);
+
if (legacyKeys.length > 0) {
if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
if (LEGACY_RAW_TEXT_KEY in savedVal) {
).some((key) => savedVal[key] === false);
}
}
+
for (const key of legacyKeys) {
delete (this.config as Record<string, unknown>)[key];
}
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
+
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
if (!browser) return;
const legacyTheme = localStorage.getItem('theme');
+
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
*/
syncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
+
if (Object.keys(propsDefaults).length === 0) return;
const uiSettings = serverStore.uiSettings;
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
-
const normalizedCurrent = normalizeFloatingPoint(currentValue);
const normalizedDefault = normalizeFloatingPoint(propsValue);
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
+
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
+
return server;
});
+
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
}
return {
- version: 1,
- timestamp: Date.now(),
config: configToExport,
- userOverrides: Array.from(this.userOverrides)
+ timestamp: Date.now(),
+ userOverrides: Array.from(this.userOverrides),
+ version: 1
};
}
-import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
-import { ToolsService } from '$lib/services/tools.service';
-import { mcpStore } from '$lib/stores/mcp.svelte';
+import {
+ buildSandboxToolDefinition,
+ DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
+ HOME_TILDE,
+ TOOL_GROUP_LABELS,
+ TOOL_SERVER_LABELS
+} from '$lib/constants';
import {
BuiltInTool,
GlobSearchType,
ToolCallType,
ToolSource
} from '$lib/enums';
+import { ToolsService } from '$lib/services/tools.service';
+import { mcpStore } from '$lib/stores/mcp.svelte';
import { config } from '$lib/stores/settings.svelte';
-import {
- DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
- buildSandboxToolDefinition,
- HOME_TILDE,
- TOOL_GROUP_LABELS,
- TOOL_SERVER_LABELS
-} from '$lib/constants';
-
+import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
constructor() {
try {
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+
if (stored) {
const parsed = JSON.parse(stored);
+
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (typeof key === 'string') this._disabledTools.add(key);
private inferTypeFromDefault(value: unknown): string | undefined {
if (typeof value === 'string') return 'string';
+
if (typeof value === 'boolean') return 'boolean';
+
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
+
if (Array.isArray(value)) return 'array';
+
if (value !== null && typeof value === 'object') return 'object';
+
return undefined;
}
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
+
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
+
continue;
}
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const inferred = this.inferTypeFromDefault(normalizedProp.default);
+
if (inferred) normalizedProp.type = inferred;
}
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
- type: ToolCallType.FUNCTION,
function: {
- name,
description,
- parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
- }
+ name,
+ parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
+ },
+ type: ToolCallType.FUNCTION
};
}
get customTools(): OpenAIToolDefinition[] {
const raw = config().customJson;
+
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
+
if (!Array.isArray(parsed)) return [];
return parsed.filter(
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
-
const connections = mcpStore.getConnections();
+
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
+
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
- type: JsonSchemaType.OBJECT,
properties: {},
- required: []
+ required: [],
+ type: JsonSchemaType.OBJECT
};
+
out.push({
- serverId,
- serverName,
definition: {
- type: ToolCallType.FUNCTION,
function: {
- name: tool.name,
description: tool.description,
+ name: tool.name,
parameters: this.normalizeJsonSchema(rawSchema)
- }
- }
+ },
+ type: ToolCallType.FUNCTION
+ },
+ serverId,
+ serverName
});
}
}
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
out.push({
+ definition: this.mcpDefinition(tool.name, tool.description),
serverId,
- serverName,
- definition: this.mcpDefinition(tool.name, tool.description)
+ serverName
});
}
}
get allTools(): ToolEntry[] {
const entries: ToolEntry[] = [];
const seen = new SvelteSet<string>();
-
const push = (entry: ToolEntry) => {
if (seen.has(entry.key)) return;
+
seen.add(entry.key);
entries.push(entry);
};
for (const def of this._builtinTools) {
const name = def.function.name;
+
push({
- source: ToolSource.BUILTIN,
+ definition: def,
key: this.toolKey(ToolSource.BUILTIN, name),
- definition: def
+ source: ToolSource.BUILTIN
});
}
for (const def of this.frontendTools) {
const name = def.function.name;
+
push({
- source: ToolSource.FRONTEND,
+ definition: def,
key: this.toolKey(ToolSource.FRONTEND, name),
- definition: def
+ source: ToolSource.FRONTEND
});
}
- for (const { serverId, serverName, definition } of this.mcpEntries()) {
+ for (const { definition, serverId, serverName } of this.mcpEntries()) {
const name = definition.function.name;
+
push({
- source: ToolSource.MCP,
+ definition,
+ key: this.toolKey(ToolSource.MCP, name, serverId),
serverId,
serverName,
- key: this.toolKey(ToolSource.MCP, name, serverId),
- definition
+ source: ToolSource.MCP
});
}
for (const def of this.customTools) {
const name = def.function.name;
+
push({
- source: ToolSource.CUSTOM,
+ definition: def,
key: this.toolKey(ToolSource.CUSTOM, name),
- definition: def
+ source: ToolSource.CUSTOM
});
}
entry.source === ToolSource.MCP ? `mcp:${entry.serverId ?? ''}` : entry.source;
let group = byKey.get(groupKey);
+
if (!group) {
group = {
- source: entry.source,
key: groupKey,
label: this.groupLabel(entry),
serverId: entry.serverId,
+ source: entry.source,
tools: []
};
byKey.set(groupKey, group);
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
const enabledNames = new SvelteSet<string>();
+
for (const entry of this.allTools) {
if (!this._disabledTools.has(entry.key)) {
enabledNames.add(entry.definition.function.name);
const result: OpenAIToolDefinition[] = [];
const seen = new SvelteSet<string>();
-
const take = (def: OpenAIToolDefinition) => {
const name = def.function.name;
+
if (!enabledNames.has(name) || seen.has(name)) return;
+
seen.add(name);
result.push(def);
};
} else {
this._disabledTools.add(key);
}
+
this.persistDisabledTools();
}
/** Enable all tools belonging to a specific MCP server */
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
+
if (!connection) return;
+
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
+
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
+
for (const server of mcpStore.getServers()) {
if (!server.enabled) continue;
+
const health = mcpStore.getHealthCheckState(server.id);
+
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
});
}
}
+
return result;
}
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) return entry;
}
+
return null;
}
/** Get the display label for the server that owns a given tool */
getToolServerLabel(toolName: string): string {
const entry = this.findEntryByName(toolName);
+
if (!entry) return '';
+
if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
+
if (entry.source === ToolSource.BUILTIN) return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
+
if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
+
if (entry.source === ToolSource.FRONTEND) return TOOL_SERVER_LABELS[ToolSource.FRONTEND];
+
return '';
}
try {
const toolInfos = await ToolsService.list();
+
this._builtinTools = toolInfos.map((info) => info.definition);
this._cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
+
this._error = errorMessage;
+
// 403 from /tools means the server was started without --tools
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
*/
async resolveServerHome(): Promise<string | null> {
if (this._serverHome !== undefined) return this._serverHome;
+
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
- path: HOME_TILDE,
- type: GlobSearchType.DIR,
+ limit: 1,
max_depth: 1,
- limit: 1
+ path: HOME_TILDE,
+ type: GlobSearchType.DIR
});
+
this._serverHome = typeof res.base === 'string' ? res.base : null;
} catch {
// searches still work via a literal `~`, only `~` abbreviation degrades
this._serverHome = null;
}
+
return this._serverHome;
}
}
if (import.meta.env.DEV) {
version = 'dev';
+
return;
}
try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
+
if (res.ok) {
const data = await res.json();
+
version = data.version ?? '';
}
} catch {
-import type { MessageRole } from '$lib/enums';
-import { ToolCallType } from '$lib/enums';
import type {
ApiChatCompletionRequest,
ApiChatCompletionToolCall,
ApiChatMessageContentPart,
ApiChatMessageData
} from './api';
-import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat';
+import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from './database';
+import type { MessageRole } from '$lib/enums';
+import { ToolCallType } from '$lib/enums';
/**
* Agentic orchestration configuration.
+import type { ChatMessagePromptProgress, ChatRole } from './chat';
import type {
ContentPartType,
FileTypeAudio,
- ServerModelStatus,
ServerModelsSseEventType,
+ ServerModelStatus,
ServerRole
} from '$lib/enums';
-import type { ChatMessagePromptProgress, ChatRole } from './chat';
export type AudioInputFormat = FileTypeAudio.WAV | FileTypeAudio.MP3;
-import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
+import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
export interface ChatUploadedFile {
id: string;
-import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
import { AttachmentType, ReasoningEffort } from '$lib/enums';
+import type { ChatMessageTimings, ChatMessageType, ChatRole } from '$lib/types/chat';
export interface McpServerOverride {
serverId: string;
-import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp.enums';
-import type { ToolSource } from '$lib/enums/tools.enums';
+import type { MimeTypeUnion } from './common';
import type {
+ CallToolResult,
Client,
ClientCapabilities as SDKClientCapabilities,
- ServerCapabilities as SDKServerCapabilities,
+ GetPromptResult,
Implementation as SDKImplementation,
- Tool,
- CallToolResult,
Prompt,
- GetPromptResult,
PromptMessage,
+ ServerCapabilities as SDKServerCapabilities,
+ Tool,
Transport
} from '@modelcontextprotocol/sdk';
-import type { MimeTypeUnion } from './common';
import type { ColorMode } from '$lib/enums';
+import type { HealthCheckStatus, MCPConnectionPhase, MCPLogLevel } from '$lib/enums/mcp.enums';
+import type { ToolSource } from '$lib/enums/tools.enums';
export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage };
export type ClientCapabilities = SDKClientCapabilities;
-import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
-import type { OpenAIToolDefinition } from './mcp';
import type { DatabaseMessageExtra } from './database';
+import type { OpenAIToolDefinition } from './mcp';
+import type { Icon } from '@lucide/svelte';
+import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants';
import type {
ParameterSource,
- SyncableParameterType,
+ ReasoningEffort,
SettingsFieldType,
StreamConnectionState,
- ReasoningEffort
+ SyncableParameterType
} from '$lib/enums';
-import type { Icon } from '@lucide/svelte';
import type { Component } from 'svelte';
export type SettingsConfigValue = string | number | boolean | undefined;
-import type { ToolSource } from '$lib/enums';
import type { OpenAIToolDefinition } from './mcp';
+import type { ToolSource } from '$lib/enums';
export interface ToolEntry {
source: ToolSource;
// the standard DOMException name for a cancelled operation
const ABORT_ERROR_NAME = 'AbortError';
-
// browser specific TypeError messages emitted when a fetch reader is cut by page unload,
// navigation, or a transient network drop. functionally aborts, not actionable errors
const ABORT_LIKE_MESSAGE_PATTERNS = [
if (error instanceof DOMException && error.name === ABORT_ERROR_NAME) {
return true;
}
+
if (error instanceof Error) {
if (error.name === ABORT_ERROR_NAME) {
return true;
}
+
// these patterns are functionally aborts, keep them out of the red console
if (error instanceof TypeError) {
const msg = error.message ?? '';
+
if (ABORT_LIKE_MESSAGE_PATTERNS.some((re) => re.test(msg))) return true;
}
}
+
return false;
}
// If already aborted, abort immediately
if (signal.aborted) {
controller.abort(signal.reason);
+
return controller;
}
-import {
- AgenticSectionType,
- AttachmentType,
- ContinueIntentKind,
- MessageRole,
- ToolResultKind
-} from '$lib/enums';
import {
ATTACHMENT_SAVED_REGEX,
MARKDOWN_ATX_HEADING_REGEX,
- MARKDOWN_BOLD_REGEX,
MARKDOWN_BLOCKQUOTE_REGEX,
+ MARKDOWN_BOLD_REGEX,
MARKDOWN_CODE_FENCE_REGEX,
MARKDOWN_LINK_REGEX,
MARKDOWN_LIST_BULLET_REGEX,
SEARCH_SUMMARY_TOTAL_REGEX,
TOOL_RESULT_JSON_OPEN_REGEX
} from '$lib/constants';
+import {
+ AgenticSectionType,
+ AttachmentType,
+ ContinueIntentKind,
+ MessageRole,
+ ToolResultKind
+} from '$lib/enums';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
const hasContentAfterReasoning =
!!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0;
const isPending = isStreaming && !hasContentAfterReasoning;
+
sections.push({
- type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
content: message.reasoningContent,
+ type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
wasInterrupted: !isStreaming && !hasContentAfterReasoning
});
}
// 2. Text content
if (message.content?.trim()) {
sections.push({
- type: AgenticSectionType.TEXT,
- content: message.content
+ content: message.content,
+ type: AgenticSectionType.TEXT
});
}
// 3. Persisted tool calls (from message.toolCalls field)
const toolCalls = parseToolCalls(message.toolCalls);
-
// Index tool messages by toolCallId for O(1) lookup instead of O(n) find()
const toolMsgById = new Map<string, DatabaseMessage>();
+
for (const tm of toolMessages) {
if (tm.toolCallId && !toolMsgById.has(tm.toolCallId)) {
toolMsgById.set(tm.toolCallId, tm);
: isStreaming
? AgenticSectionType.TOOL_CALL_PENDING
: AgenticSectionType.TOOL_CALL;
+
sections.push({
- type,
content: resultMsg?.content || '',
- toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
+ toolCallId: tc.id,
+ toolCwd: resultMsg?.toolCwd,
+ toolName: tc.function?.name,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra,
- toolCwd: resultMsg?.toolCwd,
- toolCallId: tc.id
+ type
});
}
// 4. Streaming tool calls (not yet persisted - currently being received)
const persistedIds = new Set(toolCalls.map((t) => t.id).filter(Boolean));
+
for (const tc of streamingToolCalls) {
// Skip if already in persisted tool calls
if (tc.id && persistedIds.has(tc.id)) continue;
+
sections.push({
- type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
- toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
- toolCallId: tc.id
+ toolCallId: tc.id,
+ toolName: tc.function?.name,
+ type: AgenticSectionType.TOOL_CALL_STREAMING
});
}
}
const sections: AgenticSection[] = [];
-
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
+
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
let i = firstTurnToolMsgs.length;
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}${NEWLINE}${section.content}${REASONING_TAGS.END}`);
+
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
+
break;
case AgenticSectionType.TOOL_CALL:
const matchesText = separatorIndex === -1 ? text : text.slice(0, separatorIndex);
const summaryText =
separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY_SEPARATOR.length);
-
const totalMatch = summaryText.match(SEARCH_SUMMARY_TOTAL_REGEX);
+
if (totalMatch) {
captureTotal(parseInt(totalMatch[1], 10));
}
.join(NEWLINE);
const cacheKey = `${imageNames}:${toolResult}`;
const cached = toolResultLinesCache.get(cacheKey);
+
if (cached !== undefined) return cached;
const lines = toolResult.split(NEWLINE);
const result = lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
+
if (!match || !extras) return { text: line };
const attachmentName = match[1];
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
- return { text: line, image };
+ return { image, text: line };
});
if (toolResultLinesCache.size >= TOOL_RESULT_LINES_CACHE_MAX_SIZE) {
toolResultLinesCache.delete(toolResultLinesCache.keys().next().value!);
}
+
toolResultLinesCache.set(cacheKey, result);
return result;
if (!content) return ToolResultKind.TEXT;
const cached = classifyCache.get(content);
+
if (cached !== undefined) return cached;
const trimmed = content.trim();
+
if (!trimmed) return ToolResultKind.TEXT;
let result: ToolResultKind = ToolResultKind.TEXT;
if (classifyCache.size >= CLASSIFY_CACHE_MAX_SIZE) {
classifyCache.delete(classifyCache.keys().next().value!);
}
+
classifyCache.set(content, result);
return result;
for (const line of lines) {
if (MARKDOWN_ATX_HEADING_REGEX.test(line)) return true;
+
if (MARKDOWN_BLOCKQUOTE_REGEX.test(line)) return true;
+
if (MARKDOWN_LIST_BULLET_REGEX.test(line)) return true;
+
if (MARKDOWN_LIST_NUMBERED_REGEX.test(line)) return true;
}
// Inline structural markers anywhere in the body.
if (MARKDOWN_LINK_REGEX.test(content)) return true;
+
if (MARKDOWN_BOLD_REGEX.test(content)) return true;
// Tables: a pipe-bearing header line followed by a separator row.
if (!toolCallsJson) return [];
const cached = toolCallsParseCache.get(toolCallsJson);
+
if (cached) return cached;
let result: ApiChatCompletionToolCall[];
+
try {
const parsed = JSON.parse(toolCallsJson);
+
result = Array.isArray(parsed) ? parsed : [];
} catch {
result = [];
if (toolCallsParseCache.size >= TOOL_CALLS_CACHE_MAX_SIZE) {
toolCallsParseCache.delete(toolCallsParseCache.keys().next().value!);
}
+
toolCallsParseCache.set(toolCallsJson, result);
return result;
}
const hasToolCalls = parseToolCalls(target.toolCalls).length > 0;
+
if (!hasToolCalls) {
return { kind: ContinueIntentKind.APPEND_TEXT };
}
// messages directly after the assistant turn that owns them, so the first
// non tool message marks the boundary.
let lastTrailingTool = idx;
+
for (let i = idx + 1; i < messages.length; i++) {
if (messages[i].role === MessageRole.TOOL) {
lastTrailingTool = i;
+import { getAuthHeaders, getJsonHeaders } from './api-headers';
import { base } from '$app/paths';
-import { getJsonHeaders, getAuthHeaders } from './api-headers';
-import { UrlProtocol } from '$lib/enums';
import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants/error';
+import { UrlProtocol } from '$lib/enums';
/**
* API Fetch Utilities
*/
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
-
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
-
const url =
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
? path
: `${base}${path}`;
let response;
+
try {
response = await fetch(url, {
...fetchOptions,
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
+
throw new ApiError(errorMessage, response.status);
}
}
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
-
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
let response;
+
try {
response = await fetch(url.toString(), {
...fetchOptions,
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
+
throw new ApiError(errorMessage, response.status);
}
options: ApiFetchOptions = {}
): Promise<T> {
return apiFetch<T>(path, {
- method: 'POST',
body: JSON.stringify(body),
+ method: 'POST',
...options
});
}
async function parseErrorMessage(response: Response): Promise<string> {
try {
const errorData = await response.json();
+
if (errorData?.error?.message) {
return errorData.error.message;
}
+
if (errorData?.error && typeof errorData.error === 'string') {
return errorData.error;
}
+
if (errorData?.message) {
return errorData.message;
}
}
const httpErrorStr = HTTP_CODE_TO_STRING[response.status];
+
if (httpErrorStr) {
return httpErrorStr;
}
*/
function beautifyNetworkError(throwable: unknown): string {
let message;
+
if (throwable instanceof Error) {
message = throwable.message;
+
if (throwable.name === 'TypeError' && message.includes('fetch')) {
return ERROR_MESSAGES.NETWORK.UNREACHABLE;
}
-import { config } from '$lib/stores/settings.svelte';
+import { redactValue } from './redact';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
REDACTED_HEADERS
} from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
-import { redactValue } from './redact';
+import { config } from '$lib/stores/settings.svelte';
/**
* Get authorization headers for API requests
-import { base } from '$app/paths';
import { error } from '@sveltejs/kit';
import { browser } from '$app/environment';
+import { base } from '$app/paths';
import { AUTHORIZATION_HEADER, BEARER_PREFIX, CONTENT_TYPE_HEADER } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
}
console.warn(`Server responded with status ${response.status} during API key validation`);
+
return;
}
} catch (err) {
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
-import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
import type {
AttachmentDisplayItemsOptions,
ChatAttachmentDisplayItem,
ChatUploadedFile
} from '$lib/types';
+import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
/**
* Check if a display item represents an MCP prompt
if (item.attachment?.type === AttachmentType.MCP_PROMPT) {
return true;
}
+
if (item.uploadedFile?.type === SpecialFileType.MCP_PROMPT && item.uploadedFile.mcpPrompt) {
return true;
}
+
return false;
}
export function getAttachmentDisplayItems(
options: AttachmentDisplayItemsOptions
): ChatAttachmentDisplayItem[] {
- const { uploadedFiles = [], attachments = [] } = options;
+ const { attachments = [], uploadedFiles = [] } = options;
const items: ChatAttachmentDisplayItem[] = [];
// Add uploaded files (ChatForm)
for (const file of uploadedFiles) {
items.push({
id: file.id,
- name: file.name,
- size: file.size,
- preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
isLoading: file.isLoading,
loadError: file.loadError,
- uploadedFile: file,
- textContent: file.textContent
+ name: file.name,
+ preview: file.preview,
+ size: file.size,
+ textContent: file.textContent,
+ uploadedFile: file
});
}
const isImage = isImageFile(attachment);
items.push({
+ attachment,
+ attachmentIndex: index,
id: `attachment-${index}`,
+ isImage,
name: attachment.name,
- size: 'size' in attachment ? attachment.size : undefined,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
- isImage,
- attachment,
- attachmentIndex: index,
+ size: 'size' in attachment ? attachment.size : undefined,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
+ autoGainControl: true,
echoCancellation: true,
- noiseSuppression: true,
- autoGainControl: true
+ noiseSuppression: true
}
});
this.recordingState = true;
} catch (error) {
console.error('Failed to start recording:', error);
+
throw new Error('Failed to access microphone. Please check permissions.');
}
}
if (!recorder || recorder.state === 'inactive') {
reject(new Error('No active recording to stop'));
+
return;
}
}
const arrayBuffer = await audioBlob.arrayBuffer();
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
try {
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
return audioBufferToWav(audioBuffer);
} finally {
audioContext.close();
}
} catch (error) {
console.error('Failed to convert audio to WAV:', error);
+
return audioBlob;
}
}
const byteRate = sampleRate * blockAlign;
const dataSize = length * blockAlign;
const bufferSize = 44 + dataSize;
-
const arrayBuffer = new ArrayBuffer(bufferSize);
const view = new DataView(arrayBuffer);
-
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
// Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV)
const channels: Float32Array[] = new Array(numberOfChannels);
+
for (let c = 0; c < numberOfChannels; c++) {
channels[c] = buffer.getChannelData(c);
}
const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels);
+
let p = 0;
+
for (let i = 0; i < length; i++) {
for (let c = 0; c < numberOfChannels; c++) {
let s = channels[c][i];
+
if (s > 1) s = 1;
else if (s < -1) s = -1;
+
pcm[p++] = s * 0x7fff;
}
}
const defaultFilename = `recording-${timestamp}.${extension}`;
return new File([audioBlob], filename || defaultFilename, {
- type: audioBlob.type,
- lastModified: Date.now()
+ lastModified: Date.now(),
+ type: audioBlob.type
});
}
id: string | null | undefined
): DatabaseMessage | undefined {
if (!id) return undefined;
+
return messages.find((m) => m.id === id);
}
// Find the starting node (leaf node or latest if not found)
let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
+
if (!startNode) {
// If leaf node not found, use the message with latest timestamp
let latestTime = -1;
+
for (const msg of messages) {
if (msg.timestamp > latestTime) {
startNode = msg;
// Traverse from leaf to root, collecting messages
let currentNode: DatabaseMessage | undefined = startNode;
+
while (currentNode) {
// Include message if it's not root, or if we want to include root
if (currentNode.type !== 'root' || includeRoot) {
if (currentNode.parent === null) {
break;
}
+
currentNode = nodeMap.get(currentNode.parent);
}
// Sort: system messages first, then by timestamp
result.sort((a, b) => {
if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1;
+
if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1;
return a.timestamp - b.timestamp;
});
+
return result;
}
messageId: string
): string {
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
+
while (currentNode && currentNode.children.length > 0) {
// Follow the last child (most recent branch)
const lastChildId = currentNode.children[currentNode.children.length - 1];
+
currentNode = nodeMap.get(lastChildId);
}
*/
export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
+
return findLeafNodeInMap(nodeMap, messageId);
}
messageId: string
): ChatMessageSiblingInfo | null {
const message = nodeMap.get(messageId);
+
if (!message) {
return null;
}
if (message.parent === null) {
// No parent means this is likely a root node with no siblings
return {
+ currentIndex: 0,
message,
siblingIds: [messageId],
- currentIndex: 0,
totalSiblings: 1
};
}
const parentNode = nodeMap.get(message.parent);
+
if (!parentNode) {
// Parent not found - treat as single message
return {
+ currentIndex: 0,
message,
siblingIds: [messageId],
- currentIndex: 0,
totalSiblings: 1
};
}
// Get all sibling IDs (including self)
const siblingIds = parentNode.children;
-
// Convert sibling message IDs to their corresponding leaf node IDs
// This allows navigation between different conversation branches
const siblingLeafIds = siblingIds.map((siblingId: string) =>
findLeafNodeInMap(nodeMap, siblingId)
);
-
// Find current message's position among siblings
const currentIndex = siblingIds.indexOf(messageId);
return {
+ currentIndex,
message,
siblingIds: siblingLeafIds,
- currentIndex,
totalSiblings: siblingIds.length
};
}
): Map<string, ChatMessageSiblingInfo> {
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
const siblingMap = new Map<string, ChatMessageSiblingInfo>();
+
for (const msg of messages) {
const info = getMessageSiblings(nodeMap, msg.id);
+
if (info) {
siblingMap.set(msg.id, info);
}
}
+
return siblingMap;
}
-import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants';
+import { DEFAULT_CACHE_MAX_ENTRIES, DEFAULT_CACHE_TTL_MS } from '$lib/constants';
/**
* TTL Cache - Time-To-Live cache implementation for memory optimization
*/
get(key: K): V | null {
const entry = this.cache.get(key);
+
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.delete(key);
+
return null;
}
// Update last accessed time for LRU-like behavior
entry.lastAccessed = Date.now();
+
return entry.value;
}
const now = Date.now();
this.cache.set(key, {
- value,
expiresAt: now + ttl,
- lastAccessed: now
+ lastAccessed: now,
+ value
});
}
*/
has(key: K): boolean {
const entry = this.cache.get(key);
+
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.delete(key);
+
return false;
}
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
+
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
+
return this.cache.delete(key);
}
this.onEvict(key, entry.value);
}
}
+
this.cache.clear();
}
*/
prune(): number {
const now = Date.now();
+
let pruned = 0;
for (const [key, entry] of this.cache) {
*/
touch(key: K): boolean {
const entry = this.cache.get(key);
+
if (!entry) return false;
const now = Date.now();
+
if (now > entry.expiresAt) {
this.delete(key);
+
return false;
}
entry.expiresAt = now + this.ttlMs;
entry.lastAccessed = now;
+
return true;
}
}
get(key: K): V | null {
const entry = this.entries.get(key);
+
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
+
return null;
}
entry.lastAccessed = Date.now();
+
return entry.value;
}
const now = Date.now();
this.entries.set(key, {
- value,
expiresAt: now + ttl,
- lastAccessed: now
+ lastAccessed: now,
+ value
});
}
has(key: K): boolean {
const entry = this.entries.get(key);
+
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
+
return false;
}
prune(): number {
const now = Date.now();
+
let pruned = 0;
for (const [key, entry] of this.entries) {
+import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
import { MEGAPIXELS_TO_PIXELS } from '$lib/constants/image-size';
import { BASE64_IMAGE_URI_REGEX } from '$lib/constants/uri-template';
-import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
import { MimeTypeImage } from '$lib/enums';
/**
const orientation = isJpegMimeType(mimeType)
? getJpegOrientationFromDataURL(base64UrlImage)
: 1;
-
const img = new Image();
img.onload = () => {
if (maxPixels > 0 && totalPixels > maxPixels) {
const scaleFactor = Math.sqrt(maxPixels / totalPixels);
+
canvas.width = Math.floor(targetWidth * scaleFactor);
canvas.height = Math.floor(targetHeight * scaleFactor);
} else if (orientation > 1) {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error resizing image: ${message}`;
+
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
*/
const THINKING_KWARG_VARS = ['enable_thinking', 'reasoning_effort', 'thinking_budget'];
-
/**
* Paired thinking-content tag patterns.
*
['<seed:think|>', '</seed:think|>'],
['<think></think>', null]
];
-
const JINJA_THINKING_CONDITIONALS: RegExp[] = [
// Matches: {% if enable thinking %}, {% if enable_thinking %}, {% if (enable_thinking is defined) %}
// Handles: underscore-separated (enable_thinking), space-separated (enable thinking),
*/
export function detectThinkingSupport(t: string): boolean {
if (!t) return false;
+
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
+
if (regex.test(t)) return true;
}
for (const p of JINJA_THINKING_CONDITIONALS) {
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) return true;
}
+
return false;
}
export function detectThinkingSupportWithReason(t: string): { supported: boolean; reason: string } {
- if (!t) return { supported: false, reason: 'No chat template available' };
+ if (!t) return { reason: 'No chat template available', supported: false };
+
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
+
if (regex.test(t)) {
- return { supported: true, reason: 'Found: ' + kwarg };
+ return { reason: 'Found: ' + kwarg, supported: true };
}
}
for (const p of JINJA_THINKING_CONDITIONALS) {
- if (p.test(t)) return { supported: true, reason: 'Found: thinking conditional' };
+ if (p.test(t)) return { reason: 'Found: thinking conditional', supported: true };
}
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) {
- return { supported: true, reason: 'Found: ' + s + (e ? ' .. ' + e : ' (self)') };
+ return { reason: 'Found: ' + s + (e ? ' .. ' + e : ' (self)'), supported: true };
}
}
- return { supported: false, reason: 'No thinking patterns found' };
+
+ return { reason: 'No thinking patterns found', supported: false };
}
-import { toast } from 'svelte-sonner';
import { AttachmentType } from '$lib/enums';
import type {
+ ClipboardAttachment,
+ ClipboardMcpPromptAttachment,
+ ClipboardTextAttachment,
DatabaseMessageExtra,
- DatabaseMessageExtraTextFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
- ClipboardTextAttachment,
- ClipboardMcpPromptAttachment,
- ClipboardAttachment,
+ DatabaseMessageExtraTextFile,
ParsedClipboardContent
} from '$lib/types';
+import { toast } from 'svelte-sonner';
/**
* Copy text to clipboard with toast notification
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
+
return true;
}
// Fallback for non-secure contexts
const textArea = document.createElement('textarea');
+
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.select();
const successful = document.execCommand('copy');
+
document.body.removeChild(textArea);
if (successful) {
toast.success(successMessage);
+
return true;
} else {
throw new Error('execCommand failed');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error(errorMessage);
+
return false;
}
}
if (asPlainText) {
const parts = [content];
+
for (const att of textAttachments) {
parts.push(att.content);
}
+
return parts.join('\n\n');
}
const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
if (att.type === AttachmentType.MCP_PROMPT) {
const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
+
return {
- type: AttachmentType.MCP_PROMPT,
+ arguments: mcpAtt.arguments,
+ content: mcpAtt.content,
name: mcpAtt.name,
- serverName: mcpAtt.serverName,
promptName: mcpAtt.promptName,
- content: mcpAtt.content,
- arguments: mcpAtt.arguments
+ serverName: mcpAtt.serverName,
+ type: AttachmentType.MCP_PROMPT
} as ClipboardMcpPromptAttachment;
}
+
return {
- type: AttachmentType.TEXT,
+ content: att.content,
name: att.name,
- content: att.content
+ type: AttachmentType.TEXT
} as ClipboardTextAttachment;
});
*/
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
const defaultResult: ParsedClipboardContent = {
+ mcpPromptAttachments: [],
message: clipboardText,
- textAttachments: [],
- mcpPromptAttachments: []
+ textAttachments: []
};
if (!clipboardText.startsWith('"')) {
if (escaped) {
escaped = false;
+
continue;
}
if (char === '\\') {
escaped = true;
+
continue;
}
if (char === '"') {
stringEndIndex = i;
+
break;
}
}
const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
-
const message = JSON.parse(jsonStringPart) as string;
if (!remainingPart || !remainingPart.startsWith('[')) {
return {
+ mcpPromptAttachments: [],
message,
- textAttachments: [],
- mcpPromptAttachments: []
+ textAttachments: []
};
}
const attachments = JSON.parse(remainingPart) as unknown[];
-
const validTextAttachments: ClipboardTextAttachment[] = [];
const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
for (const att of attachments) {
if (isValidMcpPromptAttachment(att)) {
validMcpPromptAttachments.push({
- type: AttachmentType.MCP_PROMPT,
+ arguments: att.arguments,
+ content: att.content,
name: att.name,
- serverName: att.serverName,
promptName: att.promptName,
- content: att.content,
- arguments: att.arguments
+ serverName: att.serverName,
+ type: AttachmentType.MCP_PROMPT
});
} else if (isValidTextAttachment(att)) {
validTextAttachments.push({
- type: AttachmentType.TEXT,
+ content: att.content,
name: att.name,
- content: att.content
+ type: AttachmentType.TEXT
});
}
}
return {
+ mcpPromptAttachments: validMcpPromptAttachments,
message,
- textAttachments: validTextAttachments,
- mcpPromptAttachments: validMcpPromptAttachments
+ textAttachments: validTextAttachments
};
} catch {
return defaultResult;
}
const parsed = parseClipboardContent(clipboardText);
+
return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
}
-import hljs from 'highlight.js';
import {
- NEWLINE,
+ AMPERSAND_REGEX,
DEFAULT_LANGUAGE,
+ FENCE_PATTERN,
+ GT_REGEX,
LANG_PATTERN,
- AMPERSAND_REGEX,
LT_REGEX,
- GT_REGEX,
- FENCE_PATTERN,
+ NEWLINE,
TRIM_LEADING_PADDING_REGEX,
TRIM_TRAILING_PADDING_REGEX
} from '$lib/constants';
+import hljs from 'highlight.js';
export interface IncompleteCodeBlock {
language: string;
if (!markdown.includes('```')) return markdown;
const lines = markdown.split(NEWLINE);
+
let inside = false;
let changed = false;
for (let i = 0; i < lines.length; i++) {
const match = FENCE_LINE_REGEX.exec(lines[i]);
+
if (!match) continue;
if (!inside) {
inside = true;
+
continue;
}
inside = false;
const trailing = match[2];
+
if (trailing.includes('`') || !/\s/.test(trailing)) continue;
lines[i] = lines[i].slice(0, lines[i].length - trailing.length);
// (e.g., when text after a code block changes but the code itself doesn't).
const cacheKey = `${language}:${autoDetect}:${code}`;
const cached = highlightCache.get(cacheKey);
+
if (cached) return cached;
const trimmed = trimCodePadding(code);
+
let result: string;
try {
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX_SIZE) {
highlightCache.delete(highlightCache.keys().next().value!);
}
+
highlightCache.set(cacheKey, result);
return result;
// A code block is incomplete if there's an odd number of ``` fences
const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
const fences: number[] = [];
+
let fenceMatch;
while ((fenceMatch = fencePattern.exec(markdown)) !== null) {
// Store the position after the ```
const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index;
+
fences.push(pos);
}
// The last fence is the opening of the incomplete block
const openingIndex = fences[fences.length - 1];
const afterOpening = markdown.slice(openingIndex + 3);
-
// Extract language and code content
const langMatch = afterOpening.match(LANG_PATTERN);
const language = langMatch?.[1] || DEFAULT_LANGUAGE;
const code = markdown.slice(codeStartIndex);
return {
- language,
code,
+ language,
openingIndex
};
}
const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
- return { name, args, end: value.length };
+ return { args, end: value.length, name };
}
/**
export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
const token = findCommandToken(value);
+
if (!token) return null;
- return { name: token.name, args: token.args };
+
+ return { args: token.args, name: token.name };
}
export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
const oldLines = splitLines(oldText);
const newLines = splitLines(newText);
-
const m = oldLines.length;
const n = newLines.length;
if (m === 0 && n === 0) return [];
- if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, text: t, newLine: k + 1 }));
+
+ if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, newLine: k + 1, text: t }));
+
if (n === 0)
- return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, text: t, oldLine: k + 1 }));
+ return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, oldLine: k + 1, text: t }));
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
}
const result: DiffLine[] = [];
+
let i = m;
let j = n;
+
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.push({
kind: DiffLineKind.CONTEXT,
- text: oldLines[i - 1],
+ newLine: j,
oldLine: i,
- newLine: j
+ text: oldLines[i - 1]
});
i--;
j--;
} else if (lcs[i - 1][j] >= lcs[i][j - 1]) {
- result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
+ result.push({ kind: DiffLineKind.REMOVE, oldLine: i, text: oldLines[i - 1] });
i--;
} else {
- result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
+ result.push({ kind: DiffLineKind.ADD, newLine: j, text: newLines[j - 1] });
j--;
}
}
while (i > 0) {
- result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
+ result.push({ kind: DiffLineKind.REMOVE, oldLine: i, text: oldLines[i - 1] });
i--;
}
while (j > 0) {
- result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
+ result.push({ kind: DiffLineKind.ADD, newLine: j, text: newLines[j - 1] });
j--;
}
result.reverse();
+
return result;
}
*/
export function renderUnifiedDiff(lines: DiffLine[]): string {
if (lines.length === 0) return '';
+
return lines.map((l) => prefixFor(l.kind) + l.text).join('\n');
}
/** Column-1 marker for a `DiffLine`: ` `, `+`, or `-`. */
export function prefixFor(kind: DiffLineKind): string {
if (kind === DiffLineKind.ADD) return '+';
+
if (kind === DiffLineKind.REMOVE) return '-';
+
return ' ';
}
function splitLines(text: string): string[] {
if (text === '') return [];
+
const parts = text.split('\n');
+
if (parts[parts.length - 1] === '') parts.pop();
+
return parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
}
key: string
): string | number | boolean | undefined {
const value = (config as Record<string, unknown>)[key];
+
return value as string | number | boolean | undefined;
}
for (const key of keys) {
const value = getConfigValue(config, key);
+
if (value !== undefined) {
record[key] = value;
}
// Block wrappers browsers insert for newlines; each folds back into a
// single `\n` during serialization.
const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
-
// `file://` is required so plain URLs stay as text; `)` terminates only
// when not followed by whitespace or `[` (adjacent badges keep working).
const MENTION_BADGE_RE = fileMentionLinkRe('g');
function badgeSourceLength(name: string, path: string): number {
if (!name || !path) return 0;
+
return `[${name}](file://${path})`.length;
}
*/
export function containsCodeSpan(value: string): boolean {
CODE_SPAN_RE.lastIndex = 0;
+
return CODE_SPAN_RE.test(value);
}
*/
export function isOffsetInCodeBlock(source: string, offset: number): boolean {
let inside = false;
+
CODE_FENCE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
+
while ((match = CODE_FENCE_RE.exec(source)) !== null) {
if (match.index + match[0].length > offset) break;
+
inside = !inside;
}
*/
export function tokenizeContent(input: string): ContentToken[] {
const tokens: ContentToken[] = [];
+
let cursor = 0;
+
CODE_SPAN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
+
while ((match = CODE_SPAN_RE.exec(input)) !== null) {
const start = match.index;
*/
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
let cursor = 0;
+
MENTION_BADGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
+
while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
const [whole, name, path] = match;
const start = match.index;
for (const child of Array.from(parent.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
+
if (text.length > 0) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
+
out += text;
first = false;
}
+
continue;
}
if (el.dataset.mentionBadge === 'true') {
const name = el.dataset.mentionName ?? '';
const path = el.dataset.mentionPath ?? '';
+
if (name && path) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
+
out += `[${name}](file://${path})`;
first = false;
}
+
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
+
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
+
pendingBlockBoundary = false;
walk(el);
first = false;
+
if (isBlock) pendingBlockBoundary = true;
+
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
if (!isHatch && el.nextSibling) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
+
out += '\n';
first = false;
}
+
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) out += '\n';
+
pendingBlockBoundary = false;
walk(el);
first = false;
+
continue;
}
walk(el);
+
if (pendingBlockBoundary) first = false;
}
};
walk(root);
+
return out;
}
*/
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
const expected = tokens.filter((token) => token.kind !== 'text');
+
let index = 0;
const walk = (parent: Node): boolean => {
if (!isBadge && !isCode) {
if (!walk(el)) return false;
+
continue;
}
const token = expected[index++];
+
if (!token) return false;
if (isBadge) {
if (token.kind !== 'badge') return false;
+
if (token.name !== (el.dataset.mentionName ?? '')) return false;
+
if (token.path !== (el.dataset.mentionPath ?? '')) return false;
+
continue;
}
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
+
if (token.kind !== codeKind) return false;
+
if (
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
token.text !== (el.textContent ?? '')
// A point is at/before the caret iff it falls inside [root start, caret].
const pre = range.cloneRange();
+
pre.selectNodeContents(root);
pre.setEnd(range.endContainer, range.endOffset);
const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
+
if (text.length === 0) continue;
+
if (pendingPoint) {
- const { node, index } = pendingPoint;
+ const { index, node } = pendingPoint;
+
pendingPoint = null;
+
if (!atOrBeforeCaret(node, index)) {
done = true;
+
return;
}
+
total += 1;
}
+
if (!atOrBeforeCaret(child, 0)) {
done = true;
+
return;
}
+
if (range.endContainer === child) {
total += range.endOffset;
done = true;
+
return;
}
+
total += text.length;
first = false;
+
continue;
}
const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
if (pendingPoint) {
- const { node, index } = pendingPoint;
+ const { index, node } = pendingPoint;
+
pendingPoint = null;
+
if (!atOrBeforeCaret(node, index)) {
done = true;
+
return;
}
+
total += 1;
}
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+
if (len === 0) continue;
+
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
+
return;
}
+
total += len;
first = false;
+
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
+
if (isBlock && !first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
+
return;
}
+
total += 1;
}
+
walk(el);
first = false;
- if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 };
+
+ if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
+
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
if (isHatch || !el.nextSibling) continue;
+
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
+
return;
}
+
total += 1;
first = false;
+
continue;
}
if (!first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
+
return;
}
+
total += 1;
}
+
walk(el);
first = false;
+
continue;
}
const before = total;
+
walk(el);
+
if (total > before) first = false;
}
};
walk(root);
+
return total;
}
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
text = text.slice(1);
}
+
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
text = text.slice(0, -1);
}
+
if (text.length === 0) continue;
fragment.appendChild(document.createTextNode(text));
+
continue;
}
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
const code = document.createElement('code');
+
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
code.textContent = token.text;
fragment.appendChild(code);
+
continue;
}
}
const badge = document.createElement('span');
+
badge.dataset.mentionBadge = 'true';
badge.dataset.mentionName = token.name;
badge.dataset.mentionPath = token.path;
badge.contentEditable = 'false';
const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
+
for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
svg.setAttribute(attr, value);
}
for (const d of getMentionBadgeIconPaths(token.path)) {
const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
+
path.setAttribute('d', d);
svg.appendChild(path);
}
const label = document.createElement('span');
+
label.classList.add('shrink-0', 'truncate');
label.textContent = getMentionBadgeLabel(
token.name,
// (badge, another block, an existing hatch) or a non-empty text node.
function hasLineBeside(node: Node | null): boolean {
if (!node) return false;
+
if (node.nodeType === Node.ELEMENT_NODE) return true;
+
return (node.textContent ?? '') !== '';
}
// A `<br>` with no code block around is a real newline (browser
// Shift+Enter shape) and stays.
let prevElement = child.previousSibling;
+
while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
prevElement = prevElement.previousSibling;
}
for (const child of Array.from(root.childNodes)) {
if (child.nodeType !== Node.TEXT_NODE) continue;
+
if (!isCodeBlockElement(child.previousSibling)) continue;
let text = child.textContent ?? '';
+
if (!/^\n{2,}$/.test(text)) continue;
text = text.slice(1);
const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
+
if (atBufferEnd) {
text = text.slice(0, -1);
}
direction: 'forward' | 'backward'
): number | null {
let masked = '';
+
const badgeSpans: Array<[number, number]> = [];
for (const token of tokenizeContent(source)) {
const len =
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
+
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
+
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
}
const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
const n = masked.length;
+
let i = offset;
if (direction === 'forward') {
if (!(i < n && isWord(i))) {
while (i < n && !isWord(i)) i++;
}
+
while (i < n && isWord(i)) {
const span = spanStartingAt(i);
+
if (span) {
i = span[1];
+
break;
}
+
i++;
}
} else {
if (!(i > 0 && isWord(i - 1))) {
while (i > 0 && !isWord(i - 1)) i--;
}
+
while (i > 0 && isWord(i - 1)) {
const span = spanEndingAt(i);
+
if (span) {
i = span[0];
+
break;
}
+
i--;
}
}
const lo = Math.min(offset, i);
const hi = Math.max(offset, i);
+
return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
}
*/
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
const [first] = tokenizeContent(source);
+
if (!first || first.kind !== 'badge') return null;
+
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
}
*/
export function textOffsetToRange(root: HTMLElement, offset: number): Range {
const range = document.createRange();
+
let remaining = offset;
let landed = false;
let pendingBlockBoundary = false;
range.setEnd(node, nodeOffset);
landed = true;
};
-
const walk = (parent: Node) => {
let first = true;
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
+
if (text.length === 0) continue;
+
if (pendingBlockBoundary) {
// The synthesized separator maps to the near edge of the
// content that follows the block.
pendingBlockBoundary = false;
+
if (remaining === 0) {
land(child, 0);
+
return;
}
+
remaining -= 1;
}
+
if (remaining <= text.length) {
land(child, remaining);
+
return;
}
+
remaining -= text.length;
first = false;
+
continue;
}
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+
if (len === 0) continue;
+
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
+
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
remaining -= 1;
}
+
if (remaining <= len) {
if (remaining === 0) {
range.setStartBefore(el);
range.setStartAfter(el);
range.setEndAfter(el);
}
+
landed = true;
+
return;
}
+
remaining -= len;
first = false;
+
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
+
if (isBlock && (pendingBlockBoundary || !first)) {
pendingBlockBoundary = false;
+
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
remaining -= 1;
}
const len = (el.textContent ?? '').length;
+
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
if (remaining === len) {
range.setStartAfter(el);
range.setEndAfter(el);
landed = true;
+
return;
}
+
if (remaining < len) {
walk(el);
+
return;
}
+
remaining -= len;
+
if (isBlock) remaining -= 1;
+
first = false;
+
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
if (isHatch) {
// Escape hatch: no source length; offset 0 lands before it
// so text typed there takes its place.
range.setEndBefore(el);
landed = true;
}
+
continue;
}
+
if (!el.nextSibling) continue;
+
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
+
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
remaining -= 1;
}
+
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
remaining -= 1;
first = false;
+
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) {
pendingBlockBoundary = false;
+
if (remaining === 0) {
// The boundary newline belongs to the previous line.
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
+
return;
}
+
remaining -= 1;
}
+
walk(el);
first = false;
+
continue;
}
const before = remaining;
+
walk(el);
+
if (remaining < before) first = false;
}
};
if (!landed) {
const last = root.lastChild;
+
if (last && last.nodeName === 'BR') {
range.setStartBefore(last);
range.setEndBefore(last);
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
+import { isLikelyTextFile, readFileAsText } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
-import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import type { ChatUploadedFile, DatabaseMessageExtra, FileProcessingResult } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
-import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';
-import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types';
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
// Extract base64 data without the data URL prefix
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1];
+
resolve(base64);
};
for (const file of files) {
if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
extras.push({
- type: AttachmentType.MCP_PROMPT,
+ arguments: file.mcpPrompt.arguments,
+ content: file.textContent ?? '',
name: file.name,
- size: file.size,
- serverName: file.mcpPrompt.serverName,
promptName: file.mcpPrompt.promptName,
- content: file.textContent ?? '',
- arguments: file.mcpPrompt.arguments
+ serverName: file.mcpPrompt.serverName,
+ size: file.size,
+ type: AttachmentType.MCP_PROMPT
});
continue;
}
extras.push({
- type: AttachmentType.IMAGE,
+ base64Url,
name: file.name,
size: file.size,
- base64Url
+ type: AttachmentType.IMAGE
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
const base64Data = await readFileAsBase64(file.file);
extras.push({
- type: AttachmentType.AUDIO,
+ base64Data: base64Data,
+ mimeType: file.type,
name: file.name,
size: file.size,
- base64Data: base64Data,
- mimeType: file.type
+ type: AttachmentType.AUDIO
});
} catch (error) {
console.error(`Failed to process audio file ${file.name}:`, error);
const base64Data = await readFileAsBase64(file.file);
extras.push({
- type: AttachmentType.VIDEO,
+ base64Data: base64Data,
+ mimeType: file.type,
name: file.name,
size: file.size,
- base64Data: base64Data,
- mimeType: file.type
+ type: AttachmentType.VIDEO
});
} catch (error) {
console.error(`Failed to process video file ${file.name}:`, error);
);
extras.push({
- type: AttachmentType.PDF,
- name: file.name,
- size: file.size,
+ base64Data: base64Data,
content: `PDF file with ${images.length} pages`,
images: images,
+ name: file.name,
processedAsImages: true,
- base64Data: base64Data
+ size: file.size,
+ type: AttachmentType.PDF
});
} catch (imageError) {
console.warn(
const content = await convertPDFToText(file.file);
extras.push({
- type: AttachmentType.PDF,
- name: file.name,
- size: file.size,
+ base64Data: base64Data,
content: content,
+ name: file.name,
processedAsImages: false,
- base64Data: base64Data
+ size: file.size,
+ type: AttachmentType.PDF
});
}
} else {
});
extras.push({
- type: AttachmentType.PDF,
- name: file.name,
- size: file.size,
+ base64Data: base64Data,
content: content,
+ name: file.name,
processedAsImages: false,
- base64Data: base64Data
+ size: file.size,
+ type: AttachmentType.PDF
});
}
} catch (error) {
emptyFiles.push(file.name);
} else if (isLikelyTextFile(content)) {
extras.push({
- type: AttachmentType.TEXT,
+ content: content,
name: file.name,
size: file.size,
- content: content
+ type: AttachmentType.TEXT
});
} else {
console.warn(`File ${file.name} appears to be binary and will be skipped`);
}
}
- return { extras, emptyFiles };
+ return { emptyFiles, extras };
}
// Handle MIME types (contains '/')
if (input.includes('/')) {
const subtype = input.split('/').pop();
+
if (subtype) {
// Handle special cases like 'vnd.ms-excel' → 'EXCEL'
if (subtype.includes('.')) {
return subtype.split('.').pop()?.toUpperCase() || 'FILE';
}
+
return subtype.toUpperCase();
}
}
// Handle file names (contains '.')
if (input.includes('.')) {
const ext = input.split('.').pop();
+
if (ext) return ext.toUpperCase();
}
import {
AUDIO_FILE_TYPES,
- VIDEO_FILE_TYPES,
IMAGE_FILE_TYPES,
PDF_FILE_TYPES,
- TEXT_FILE_TYPES
+ TEXT_FILE_TYPES,
+ VIDEO_FILE_TYPES
} from '$lib/constants';
import {
FileExtensionAudio,
FileTypeCategory,
MimeTypeApplication,
MimeTypeAudio,
- MimeTypeVideo,
MimeTypeImage,
- MimeTypeText
+ MimeTypeText,
+ MimeTypeVideo
} from '$lib/enums';
function normalizeMimeType(mimeType: string): string {
// Images are detected and handled separately for vision models
if (mimeType) {
const category = getFileTypeCategory(mimeType);
+
if (
category === FileTypeCategory.IMAGE ||
category === FileTypeCategory.AUDIO ||
// Check extension for known types (especially images without MIME)
const extCategory = getFileTypeCategoryByExtension(filename);
+
if (
extCategory === FileTypeCategory.IMAGE ||
extCategory === FileTypeCategory.AUDIO ||
import {
+ MEDIUM_DURATION_THRESHOLD,
MS_PER_SECOND,
- SECONDS_PER_MINUTE,
SECONDS_PER_HOUR,
- SHORT_DURATION_THRESHOLD,
- MEDIUM_DURATION_THRESHOLD
+ SECONDS_PER_MINUTE,
+ SHORT_DURATION_THRESHOLD
} from '$lib/constants';
/**
*/
export function formatFileSize(bytes: number | unknown): string {
if (typeof bytes !== 'number') return 'Unknown';
+
if (bytes === 0) return '0 Bytes';
const k = 1024;
export function formatJsonPretty(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString);
+
return JSON.stringify(parsed, null, 2);
} catch {
return jsonString;
*/
export function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
- hour12: false,
hour: '2-digit',
+ hour12: false,
minute: '2-digit',
second: '2-digit'
});
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
-
const parts: string[] = [];
if (hours > 0) {
extra?: string
): string {
const header = extra ? `${name} (${extra})` : name;
+
return `\n\n--- ${label}: ${header} ---\n${content}`;
}
* result instead of re-walking the tree.
*/
-import { BuiltInTool, GlobSearchType } from '$lib/enums';
-import { ToolsService } from '$lib/services/tools.service';
+import { lastPathSegment } from './path-display';
+import {
+ buildGlobSearchArgs,
+ type GlobEntry,
+ type GlobSearchArgs,
+ joinPath,
+ rankEntries
+} from './working-directory';
import {
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
PATH_SEPARATOR,
WINDOWS_SEPARATOR
} from '$lib/constants';
-import { lastPathSegment } from './path-display';
-import {
- buildGlobSearchArgs,
- joinPath,
- rankEntries,
- type GlobEntry,
- type GlobSearchArgs
-} from './working-directory';
+import { BuiltInTool, GlobSearchType } from '$lib/enums';
+import { ToolsService } from '$lib/services/tools.service';
const SEARCH_CACHE_TTL_MS = 2000;
): Promise<GlobSearchResult> {
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
const cached = searchCache.get(key);
+
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
- { path: args.path, type, include: args.include, max_depth: args.maxDepth, limit },
+ { include: args.include, limit, max_depth: args.maxDepth, path: args.path, type },
signal
);
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
+
// prune stale entries so the short-lived cache cannot grow unbounded
for (const [k, v] of searchCache) {
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
}
- searchCache.set(key, { results: entries, base, at: now });
+ searchCache.set(key, { at: now, base, results: entries });
+
return { base, entries };
}
}
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
- return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
+ return { name: lastPathSegment(e.path), path: joinPath(base, e.path), type: e.type };
}
/**
options: GlobSearchChildOptions = {}
): Promise<GlobSearchChildResult> {
const {
- type = GlobSearchType.ALL,
+ childMaxDepth = PATH_NAV_MAX_DEPTH,
descendOnTrailingSeparator = false,
- childMaxDepth = PATH_NAV_MAX_DEPTH
+ type = GlobSearchType.ALL
} = options;
-
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
const res = await runGlobSearch(args, type, limit, signal);
- if (res.error) return { base: res.base, args, entries: [], error: res.error };
+
+ if (res.error) return { args, base: res.base, entries: [], error: res.error };
const ranked = rankEntries(res.entries, args.rankQuery);
const entries = ranked.map((e) => toEntryResult(e, res.base));
-
const last = args.last;
+
if (last) {
const wantsDescend = descendOnTrailingSeparator
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
const exact = ranked.find(
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
);
+
if (wantsDescend && exact) {
const exactDir = joinPath(res.base, exact.path);
const childRes = await runGlobSearch(
- { path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
+ { include: GLOB_WILDCARD, maxDepth: childMaxDepth, path: exactDir, rankQuery: '' },
type,
limit,
signal
);
+
if (!childRes.error) {
const children = childRes.entries
.map((e) => toEntryResult(e, childRes.base))
.sort((a, b) => a.path.localeCompare(b.path));
- return { base: res.base, args, entries: [...entries, ...children], exactDir };
+
+ return { args, base: res.base, entries: [...entries, ...children], exactDir };
}
}
}
- return { base: res.base, args, entries };
+ return { args, base: res.base, entries };
}
try {
const parsed = JSON.parse(headersJson);
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
-import { MimeTypeImage } from '$lib/enums';
import { HEIC_JPEG_QUALITY } from '$lib/constants/image-size';
+import { MimeTypeImage } from '$lib/enums';
// heic requires a relatively large decoder, in order to reduce primary bundle size
// we lazily load this decoder from a CDN when needed, and cache it for future conversions
const { heicTo } = await getHeicTo();
const jpegBlob = await heicTo({
blob: file,
- type: MimeTypeImage.JPEG,
- quality: HEIC_JPEG_QUALITY
+ quality: HEIC_JPEG_QUALITY,
+ type: MimeTypeImage.JPEG
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
+
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(jpegBlob);
import {
+ APP1_MARKER,
+ EXIF_ORIENTATION_TAG,
EXIF_SCAN_BYTE_LIMIT,
+ EXIF_SIGNATURE,
+ IFD_ENTRY_SIZE,
JPEG_SOI_MARKER,
- APP1_MARKER,
SOS_MARKER,
- EXIF_SIGNATURE,
TIFF_LITTLE_ENDIAN,
- TIFF_MAGIC,
- EXIF_ORIENTATION_TAG,
- IFD_ENTRY_SIZE
+ TIFF_MAGIC
} from '$lib/constants/jpeg-exif';
import { MimeTypeImage } from '$lib/enums';
LATEX_INLINE_CONVERT_REGEXP,
LATEX_INLINE_DELIMITER,
LATEX_INLINE_OPEN,
+ LATEX_LINEBREAK_REGEXP,
LATEX_MATH_AND_CODE_PATTERN,
LATEX_MHCHEM_CE,
LATEX_MHCHEM_PU,
- LATEX_LINEBREAK_REGEXP,
LATEX_NEIGHBOR_CHAR_REGEXP,
LATEX_NON_WHITESPACE_REGEXP,
LATEX_PLACEHOLDER_REGEXP,
if (!content.includes(LATEX_INLINE_DELIMITER)) {
return content;
}
+
return content
.split(NEWLINE)
.map((line) => {
if (openDollarIndex == -1) {
processedLine += line.slice(currentPosition);
+
break;
}
if (closeDollarIndex == -1) {
processedLine += line.slice(currentPosition);
+
break;
}
// Treat as LaTeX
processedLine += line.slice(currentPosition, openDollarIndex);
const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
+
latexExpressions.push(latexContent);
processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
currentPosition = closeDollarIndex + 1;
}
const doEscapeMhchem = false;
-
/**
* Preprocesses markdown content to safely handle LaTeX math expressions while protecting
* against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
// incomplete code block stays the same across multiple tokens, so the
// full protect/restore pipeline would re-run unnecessarily.
const cached = latexCache.get(content);
+
if (cached !== undefined) return cached;
// Save original before the function mutates `content` through steps 0-8
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
+
latexCache.set(originalContent, content);
+
return content;
}
const lines = content.split(NEWLINE);
const processedLines = lines.map((line, index) => {
const match = line.match(LATEX_BLOCKQUOTE_PREFIX_REGEXP);
+
if (match) {
blockquoteMarkers.set(index, match[1]);
+
return line.slice(match[1].length);
}
+
return line;
});
+
content = processedLines.join(NEWLINE);
// Step 1: Protect code blocks
if (group1.endsWith(LATEX_BACKSLASH)) {
return match; // Backslash before \[, do nothing.
}
+
const hasSuffix = LATEX_NON_WHITESPACE_REGEXP.test(group3);
+
let optBreak;
if (hasSuffix) {
// Step 4: Restore protected LaTeX expressions (they are valid)
content = content.replace(LATEX_PLACEHOLDER_REGEXP, (_, index) => {
let expr = latexExpressions[parseInt(index)];
+
const match = expr.match(LATEX_LINEBREAK_REGEXP);
+
if (match) {
// Katex: The $$-delimiters should be in their own line
// if there are \\-line-breaks.
const formula = match[1];
const prefix = formula.startsWith(NEWLINE) ? '' : NEWLINE;
const suffix = formula.endsWith(NEWLINE) ? '' : NEWLINE;
+
expr = LATEX_DISPLAY_DELIMITER + prefix + formula + suffix + LATEX_DISPLAY_DELIMITER;
}
+
return expr;
});
const finalLines = content.split(NEWLINE);
const restoredLines = finalLines.map((line, index) => {
const marker = blockquoteMarkers.get(index);
+
return marker ? marker + line : line;
});
+
content = restoredLines.join(NEWLINE);
}
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
+
latexCache.set(originalContent, content);
return content;
-import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
-import {
- MCPTransportType,
- MCPLogLevel,
- UrlProtocol,
- MimeTypePrefix,
- MimeTypeIncludes,
- UriPattern,
- MimeTypeText
-} from '$lib/enums';
-import {
- MCP_SERVER_ID_PREFIX,
- IMAGE_FILE_EXTENSION_REGEX,
- CODE_FILE_EXTENSION_REGEX,
- TEXT_FILE_EXTENSION_REGEX,
- PROTOCOL_PREFIX_REGEX,
- FILE_EXTENSION_REGEX,
- DISPLAY_NAME_SEPARATOR_REGEX,
- PATH_SEPARATOR,
- RESOURCE_TEXT_CONTENT_SEPARATOR,
- DEFAULT_RESOURCE_FILENAME,
- MCP_SSE_ENDPOINT,
- MCP_SSE_ENDPOINT_SLASH,
- MCP_SSE_ENDPOINT_QUERY
-} from '$lib/constants';
import {
+ AlertTriangle,
+ Code,
Database,
File,
FileText,
Image,
- Code,
Info,
- AlertTriangle,
XCircle
} from '@lucide/svelte';
-import type { Component } from 'svelte';
+import {
+ CODE_FILE_EXTENSION_REGEX,
+ DEFAULT_RESOURCE_FILENAME,
+ DISPLAY_NAME_SEPARATOR_REGEX,
+ FILE_EXTENSION_REGEX,
+ IMAGE_FILE_EXTENSION_REGEX,
+ MCP_SERVER_ID_PREFIX,
+ MCP_SSE_ENDPOINT,
+ MCP_SSE_ENDPOINT_QUERY,
+ MCP_SSE_ENDPOINT_SLASH,
+ PATH_SEPARATOR,
+ PROTOCOL_PREFIX_REGEX,
+ RESOURCE_TEXT_CONTENT_SEPARATOR,
+ TEXT_FILE_EXTENSION_REGEX
+} from '$lib/constants';
+import {
+ MCPLogLevel,
+ MCPTransportType,
+ MimeTypeIncludes,
+ MimeTypePrefix,
+ MimeTypeText,
+ UriPattern,
+ UrlProtocol
+} from '$lib/enums';
+import type { MCPResourceContent, MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import type { MimeTypeUnion } from '$lib/types/common';
+import type { Component } from 'svelte';
/**
* Detects the MCP transport type from a URL.
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
+
if (!trimmed) return [];
try {
: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
return {
- id,
- enabled: Boolean((entry as { enabled?: unknown })?.enabled),
- url,
- name: (entry as { name?: string })?.name,
displayName: (entry as { displayName?: string })?.displayName,
+ enabled: Boolean((entry as { enabled?: unknown })?.enabled),
headers: headers || undefined,
+ id,
+ name: (entry as { name?: string })?.name,
+ url,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
export function parseResourcePath(uri: string): string[] {
try {
const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
+
return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
} catch {
return [uri];
*/
export function getDisplayName(pathPart: string): string {
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
+
return withoutExt
.split(DISPLAY_NAME_SEPARATOR_REGEX)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
export function getResourceDisplayName(resource: MCPResourceInfo): string {
try {
const parts = parseResourcePath(resource.uri);
+
return parts[parts.length - 1] || resource.name || resource.uri;
} catch {
return resource.name || resource.uri;
export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
+
return (
mime.includes(MimeTypeIncludes.JSON) ||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
+
return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
}
*/
export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
if (!content) return '';
+
return content
.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
.map((c) => c.text)
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+
a.href = url;
a.download = filename;
document.body.appendChild(a);
import { abbreviateHome, lastPathSegment } from './path-display';
+import { FILE_URI_PREFIX } from '$lib/constants';
import {
MENTION_BADGE_FILE_ICON_PATHS,
MENTION_BADGE_FOLDER_ICON_PATHS
} from '$lib/constants/mention-badge';
-import { FILE_URI_PREFIX } from '$lib/constants';
import { FileMentionEntryType } from '$lib/enums';
import type { FileMentionEntry } from '$lib/types';
home?: string | null
): string {
if (!showFullPath) return name;
+
const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
+
if (!decoded) return name;
+
return abbreviateHome(decoded, home);
}
token: { start: number; end: number }
): { newValue: string; caretOffset: number } | null {
if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
+
// Strip the entry's directory marker so it is not doubled below.
const cleanedPath = entry.path.replace(/\/+$/, '');
const pathWithSeparator =
const basename = lastPathSegment(cleanedPath) || entry.name;
const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
- return { newValue, caretOffset: token.start + insertion.length };
+
+ return { caretOffset: token.start + insertion.length, newValue };
}
if (cursor <= 0 || cursor > value.length) return null;
let atIndex = -1;
+
for (let i = cursor - 1; i >= 0; i--) {
const ch = value[i];
+
if (ch === '@') {
const prev = i > 0 ? value[i - 1] : '';
+
if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
atIndex = i;
}
+
break;
}
+
if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
}
if (atIndex === -1) return null;
let end = atIndex + 1;
+
while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
end++;
}
return {
- start: atIndex,
end,
- query: value.slice(atIndex + 1, end)
+ query: value.slice(atIndex + 1, end),
+ start: atIndex
};
}
cursor: number
): MentionDismissSnapshot | null {
const token = findMentionToken(value, cursor);
+
if (!token) return null;
- return { start: token.start, query: token.query };
+
+ return { query: token.query, start: token.start };
}
* Ensures only compatible file types are processed based on model capabilities
*/
-import { getFileTypeCategory } from '$lib/utils';
import { FileTypeCategory } from '$lib/enums';
import type { ModalityCapabilities } from '$lib/types';
+import { getFileTypeCategory } from '$lib/utils';
/**
* Check if a file type is supported by the given modalities
const supportedFiles: File[] = [];
const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {};
-
- const { hasVision, hasAudio, hasVideo } = capabilities;
+ const { hasAudio, hasVideo, hasVision } = capabilities;
for (const file of files) {
const category = getFileTypeCategory(file.type);
+
let isSupported = true;
let reason = '';
isSupported = false;
reason = 'Images require a vision-capable model';
}
+
break;
case FileTypeCategory.AUDIO:
isSupported = false;
reason = 'Audio files require an audio-capable model';
}
+
break;
case FileTypeCategory.VIDEO:
isSupported = false;
reason = 'Video files require a video-capable model';
}
+
break;
case FileTypeCategory.TEXT:
}
}
- return { supportedFiles, unsupportedFiles, modalityReasons };
+ return { modalityReasons, supportedFiles, unsupportedFiles };
}
/**
): string {
if (unsupportedFiles.length === 0) return '';
- const { hasVision, hasAudio, hasVideo } = capabilities;
+ const { hasAudio, hasVideo, hasVision } = capabilities;
let message = '';
if (unsupportedFiles.length === 1) {
const file = unsupportedFiles[0];
const reason = modalityReasons[file.name];
+
message = `The file "${file.name}" cannot be uploaded: ${reason}.`;
} else {
const fileNames = unsupportedFiles.map((f) => f.name).join(', ');
+
message = `The following files cannot be uploaded: ${fileNames}.`;
}
// Add helpful information about what is supported
const supportedTypes: string[] = ['text files', 'PDFs'];
+
if (hasVision) supportedTypes.push('images');
+
if (hasAudio) supportedTypes.push('audio files');
+
if (hasVideo) supportedTypes.push('video files');
message += ` This model supports: ${supportedTypes.join(', ')}.`;
toolResultString: string | undefined
): string | undefined {
if (!toolResultString) return undefined;
+
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (
parsed &&
typeof parsed === 'object' &&
} catch {
// Plain-text result = stdout/stderr, no structured error to surface.
}
+
return undefined;
}
if (!toolResultString) return undefined;
const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
+
if (!match) return undefined;
return {
code: Number.parseInt(match[1], 10),
- timedOut: match[0].includes('exit due to timed out'),
- rawText: match[0]
+ rawText: match[0],
+ timedOut: match[0].includes('exit due to timed out')
};
}
status: ExecShellExitStatus | undefined
): boolean {
if (!status) return false;
+
return lineText.trim() === status.rawText.trim();
}
const JSON_OBJECT_CLOSE = '}';
const JSON_ARRAY_OPEN = '[';
const JSON_ARRAY_CLOSE = ']';
-
// Trailing punctuation to strip before re-closing a partial object/array.
// Matches an optional trailing comma plus any trailing whitespace; lets
// us re-emit a syntactically-valid JSON document without an orphaned
// comma when the model cut off mid-key.
const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/;
-
/** Bounded cache for parsePartialJsonArgs results. */
const PARTIAL_JSON_CACHE_MAX_SIZE = 32;
const partialJsonCache = new Map<string, Record<string, unknown> | null>();
if (partialJsonCache.size >= PARTIAL_JSON_CACHE_MAX_SIZE) {
partialJsonCache.delete(partialJsonCache.keys().next().value!);
}
+
partialJsonCache.set(input, result);
}
// render during streaming even when toolArgs hasn't changed.
export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null {
const cached = partialJsonCache.get(toolArgsString);
+
if (cached !== undefined) return cached;
let result: Record<string, unknown> | null;
try {
const parsed: unknown = JSON.parse(toolArgsString);
+
result =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
}
cacheResult(toolArgsString, result);
+
return result;
}
function scanPartialJson(toolArgsString: string): Record<string, unknown> | null {
let inString = false;
let escape = false;
+
const stack: ('{' | '[')[] = [];
for (let i = 0; i < toolArgsString.length; i++) {
const ch = toolArgsString[i];
+
if (escape) {
escape = false;
+
continue;
}
+
if (ch === JSON_BACKSLASH && inString) {
escape = true;
+
continue;
}
+
if (ch === JSON_QUOTE) {
inString = !inString;
+
continue;
}
+
if (inString) continue;
+
if (ch === JSON_OBJECT_OPEN) stack.push(JSON_OBJECT_OPEN);
else if (ch === JSON_OBJECT_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_OBJECT_OPEN) return null;
+
stack.pop();
} else if (ch === JSON_ARRAY_OPEN) stack.push(JSON_ARRAY_OPEN);
else if (ch === JSON_ARRAY_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_ARRAY_OPEN) return null;
+
stack.pop();
}
}
let completed = toolArgsString;
+
if (escape) {
// Dangling escape at end of partial JSON: escape the trailing
// backslash as a literal so we can close the string cleanly.
completed += JSON_BACKSLASH;
}
+
if (inString) completed += JSON_QUOTE;
+
if (!inString) completed = completed.replace(TRAILING_JSON_PUNCTUATION_REGEX, '');
// Close in reverse nesting order: innermost container first.
try {
const parsed: unknown = JSON.parse(completed);
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
-import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
-import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
import {
CWD_CHANGED_PREFIX,
CWD_CLEARED_TEXT,
HOME_TILDE,
HOME_TILDE_PREFIX
} from '$lib/constants';
+import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
+import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
export function lastPathSegment(p: string): string {
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
+
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
}
home: string | null | undefined
): string {
if (!path) return '';
+
if (!home) return lastPathSegment(path);
+
if (path === home) return HOME_TILDE;
+
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
+
return lastPathSegment(path);
}
// unchanged - used where the full path matters.
export function abbreviateHome(path: string, home: string | null | undefined): string {
if (!home) return path;
+
if (path === home) return HOME_TILDE;
+
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
+
return path;
}
*/
export function formatCwdMessage(cwd: string, home: string | null): string {
const display = abbreviateWorkingDir(cwd, home);
+
return `${CWD_CHANGED_PREFIX}[${FILE_URI_PREFIX}${cwd}](${display}).`;
}
*/
export function parseCwdMessage(content: string): CwdMessageInfo | null {
const trimmed = content.trim();
+
if (trimmed === CWD_CLEARED_TEXT) {
- return { path: null, display: '' };
+ return { display: '', path: null };
}
+
if (trimmed.startsWith(CWD_CHANGED_PREFIX)) {
const rest = trimmed.slice(CWD_CHANGED_PREFIX.length);
// not anchored to the end: guidance may follow the link
const link = rest.match(CWD_LINK_REGEX);
- if (link) return { path: link[1], display: link[2] };
- return { path: rest, display: rest };
+
+ if (link) return { display: link[2], path: link[1] };
+
+ return { display: rest, path: rest };
}
+
return null;
}
import('pdfjs-dist/build/pdf.worker.min.mjs?raw')
.then((workerModule) => {
const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' });
+
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob);
})
.catch(() => {
async function getFileAsBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+
reader.onload = (event) => {
if (event.target?.result) {
resolve(event.target.result as ArrayBuffer);
const buffer = await getFileAsBuffer(file);
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
const numPages = pdf.numPages;
-
const textContentPromises: Promise<TextContent>[] = [];
for (let i = 1; i <= numPages; i++) {
return textItems.join('\n');
} catch (error) {
console.error('Error converting PDF to text:', error);
+
throw new Error(
`Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
const task = page.render({
+ canvas: canvas,
canvasContext: ctx,
- viewport: viewport,
- canvas: canvas
+ viewport: viewport
});
+
pages.push(
task.promise.then(() => {
return canvas.toDataURL(MimeTypeImage.PNG);
return await Promise.all(pages);
} catch (error) {
console.error('Error converting PDF to images:', error);
+
throw new Error(
`Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
const target = document.body;
+
if (!target) {
return;
}
+import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
+import { convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
-import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
-import { FileTypeCategory } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
+import { FileTypeCategory } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
-import { toast } from 'svelte-sonner';
import { getFileTypeCategory } from '$lib/utils';
-import { convertPDFToText } from './pdf-processing';
+import { toast } from 'svelte-sonner';
/**
* Read a file as a data URL (base64 encoded)
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
function readFileAsUTF8(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
for (const file of files) {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
const base: ChatUploadedFile = {
+ file,
id,
name: file.name,
size: file.size,
- type: file.type,
- file
+ type: file.type
};
try {
preview = await heicFileToJpegDataURL(file);
} catch (err) {
console.error('Failed to convert HEIC to PNG:', err);
+
continue;
}
}
// Extract text content from PDF for preview
try {
const textContent = await convertPDFToText(file);
+
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to extract text from PDF, adding without content:', err);
? modelsStore.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
+
if (hasVisionSupport && !currentConfig.pdfAsImage) {
toast.info(`You can enable parsing PDF as images with vision models.`, {
- duration: 8000,
action: {
label: 'Enable PDF as Images',
onClick: () => {
duration: 3000
});
}
- }
+ },
+ duration: 8000
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
// Generate preview URL for audio files
const preview = await readFileAsDataURL(file);
+
results.push({ ...base, preview });
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Generate preview URL for video files
const preview = await readFileAsDataURL(file);
+
results.push({ ...base, preview });
} else {
// Fallback: treat unknown files as text
try {
const textContent = await readFileAsUTF8(file);
+
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to read file as text, adding without content:', err);
// The server may emit a progress event before the stage plan is known, so
// `stages` can be absent. Fall back to the raw value in that case.
- const { stages = [], current, value } = progress;
+ const { current, stages = [], value } = progress;
const tailCount = Math.max(stages.length - 1, 0);
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
const idx = stages.indexOf(current);
if (!progress) return null;
const label = modelLoadStageLabel(progress.current);
+
if (!label) return null;
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
-import DOMPurify from 'dompurify';
import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants';
+import DOMPurify from 'dompurify';
/**
* Sanitizes a raw svg string for safe inline rendering.
import {
KEY_VALUE_PAIR_KEY_MAX_LENGTH,
- KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
KEY_VALUE_PAIR_UNSAFE_KEY_RE,
- KEY_VALUE_PAIR_UNSAFE_VALUE_RE
+ KEY_VALUE_PAIR_UNSAFE_VALUE_RE,
+ KEY_VALUE_PAIR_VALUE_MAX_LENGTH
} from '$lib/constants';
/**
const SEPARATOR_LINE_RE = /^\s*---\s*$/;
const URL_SCHEME_RE = /^https?:\/\//i;
-
// Match either Unix or Windows line endings so chunking/parsing handles
// payloads written by either scheme without off-by-one mismatches.
const LINE_BREAK_RE = /\r?\n/;
-
// Sentinel the search-result wire format uses when a field is absent
// (e.g. `Author: N/A`). Treated identically to a missing field so the
// rendered card hides the row either way.
const NOT_AVAILABLE_VALUE = 'N/A';
-
// Section header that announces the start of the multi-line Highlights
// block. Everything from that line onward (until the next `---`
// separator or end of chunk) is captured verbatim as highlight text
// instead of being re-scanned for `Title:`/`URL:`/... field lines.
const HIGHLIGHTS_SECTION_HEADER = 'Highlights:';
-
// Field name conventionally used by web-search tools (Exa etc.) as the
// user-supplied query parameter. Extracted so future tool schemas that
// adopt the same convention stay grep-compatible with this parser.
const SEARCH_TOOL_QUERY_FIELD = 'query';
-
// URL schemes the favicon helper will resolve to a hosted favicon. Any
// other scheme (e.g. data:, blob:) intentionally returns null so the UI
// can fall back to a generic globe icon.
const RESOLVABLE_URL_PROTOCOLS: readonly string[] = ['https:', 'http:'];
-
// Conventional favicon path served by virtually every web host.
// Appended to the URL origin as a best-effort lookup target; ignore
// 404s at render time.
function splitChunks(text: string): string[] {
const lines = text.split(LINE_BREAK_RE);
const chunks: string[] = [];
+
let buffer: string[] = [];
+
for (const line of lines) {
if (SEPARATOR_LINE_RE.test(line)) {
if (buffer.length > 0) {
buffer.push(line);
}
}
+
if (buffer.length > 0) chunks.push(buffer.join('\n'));
+
return chunks;
}
*/
function parseChunk(chunk: string): SearchResult | null {
const trimmed = chunk.trim();
+
if (!trimmed) return null;
const lines = chunk.split(LINE_BREAK_RE);
-
const fields: Record<FieldKey, string | undefined> = {
- [FieldKey.TITLE]: undefined,
- [FieldKey.URL]: undefined,
+ [FieldKey.AUTHOR]: undefined,
[FieldKey.PUBLISHED]: undefined,
- [FieldKey.AUTHOR]: undefined
+ [FieldKey.TITLE]: undefined,
+ [FieldKey.URL]: undefined
};
const highlightLines: string[] = [];
+
let inHighlights = false;
for (const line of lines) {
if (!inHighlights && line.trim() === HIGHLIGHTS_SECTION_HEADER) {
inHighlights = true;
+
continue;
}
if (inHighlights) {
highlightLines.push(line);
+
continue;
}
for (const { key, prefix } of FIELD_PREFIXES) {
if (!line.startsWith(prefix)) continue;
+
const value = line.slice(prefix.length).trim();
+
if (value && value !== NOT_AVAILABLE_VALUE) {
fields[key] = value;
}
+
break;
}
}
return null;
const highlights = highlightLines.join('\n').trim();
-
const result: SearchResult = {
title: fields[FieldKey.TITLE],
url: fields[FieldKey.URL]
};
+
if (fields[FieldKey.PUBLISHED]) result.published = fields[FieldKey.PUBLISHED];
+
if (fields[FieldKey.AUTHOR]) result.author = fields[FieldKey.AUTHOR];
+
if (highlights) result.highlights = highlights;
+
return result;
}
if (!text) return [];
const cached = searchResultsCache.get(text);
+
if (cached) return cached;
const results: SearchResult[] = [];
+
for (const chunk of splitChunks(text)) {
const parsed = parseChunk(chunk);
+
if (parsed) results.push(parsed);
}
if (searchResultsCache.size >= SEARCH_RESULTS_CACHE_MAX_SIZE) {
searchResultsCache.delete(searchResultsCache.keys().next().value!);
}
+
searchResultsCache.set(text, results);
return results;
if (!toolArgs) return '';
const cached = searchQueryCache.get(toolArgs);
+
if (cached !== undefined) return cached;
let result = '';
+
try {
const parsed: unknown = JSON.parse(toolArgs);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
+
if (typeof candidate === 'string') result = candidate.trim();
}
} catch {
if (searchQueryCache.size >= SEARCH_QUERY_CACHE_MAX_SIZE) {
searchQueryCache.delete(searchQueryCache.keys().next().value!);
}
+
searchQueryCache.set(toolArgs, result);
return result;
export function faviconForUrl(url: string): string | null {
try {
const parsed = new URL(url);
+
if (!RESOLVABLE_URL_PROTOCOLS.includes(parsed.protocol)) return null;
+
return `${parsed.protocol}//${parsed.host}${FAVICON_PATH}`;
} catch {
return null;
*/
export function isWebSearchToolName(toolName: string | undefined | null): boolean {
if (!toolName) return false;
+
return SUPPORTED_WEB_SEARCH_TOOL_NAMES.includes(toolName);
}
push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
this.undoStack.push(entry);
+
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
+
this.lastPush = now;
this.redoStack = [];
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
+
if (!entry) return null;
+
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
+
return entry;
}
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.redoStack.pop();
+
if (!entry) return null;
+
this.undoStack.push(current);
this.lastPush = 0;
+
return entry;
}
}
signal?: AbortSignal
): AsyncGenerator<SseJsonEvent<T>> {
const reader = response.body?.getReader();
+
if (!reader) return;
const decoder = new TextDecoder();
+
let buffer = '';
try {
if (signal?.aborted) return;
const { done, value } = await reader.read();
+
if (done) break;
buffer += decoder.decode(value, { stream: true });
const records = buffer.split(SSE_RECORD_SEPARATOR);
+
buffer = records.pop() ?? '';
for (const record of records) {
if (!record) continue;
+
for (const line of record.split(SSE_LINE_SEPARATOR)) {
if (!line.startsWith(SSE_DATA_PREFIX)) continue;
+
const payload = line.slice(SSE_DATA_PREFIX.length).trim();
+
if (payload === SSE_DONE_MARKER) return;
+
if (!payload) continue;
+
try {
yield { data: JSON.parse(payload) as T };
} catch {
*/
export function streamIdentity(conversationId: string, model?: string | null): string {
if (!conversationId) return '';
+
if (!model) return conversationId;
+
return `${conversationId}::${model}`;
}
*/
export function mountSvgShadow(host: HTMLElement, markup: string, style: string): void {
const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
+
root.innerHTML = markup ? `<style>${style}</style>${markup}` : '';
}
if (!ctx) {
reject(new Error('Failed to get 2D canvas context.'));
+
return;
}
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
+
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL(MimeTypeImage.PNG));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error converting SVG to PNG: ${message}`;
+
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
*/
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants';
-import type { BinaryDetectionOptions } from '$lib/types';
import { FileExtensionText } from '$lib/enums';
+import type { BinaryDetectionOptions } from '$lib/types';
/**
* Check if a filename indicates a text file based on its extension
export function generateConversationTitle(content: string, useFirstLine: boolean = false): string {
if (useFirstLine) {
const firstLine = content.split(NEWLINE).find((line) => line.trim().length > 0);
+
return firstLine ? firstLine.trim() : content.trim();
}
toolResultString: string | undefined
): Record<string, unknown> | null {
if (!toolResultString) return null;
+
try {
const parsed: unknown = JSON.parse(toolResultString);
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
+
return null;
} catch {
return null;
import {
+ LEADING_SLASHES_REGEX,
TEMPLATE_EXPRESSION_REGEX,
URI_SCHEME_SEPARATOR,
URI_TEMPLATE_OPERATORS,
URI_TEMPLATE_SEPARATORS,
VARIABLE_EXPLODE_MODIFIER_REGEX,
- VARIABLE_PREFIX_MODIFIER_REGEX,
- LEADING_SLASHES_REGEX
+ VARIABLE_PREFIX_MODIFIER_REGEX
} from '../constants';
/**
*/
export function normalizeResourceUri(uri: string): string {
const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
+
if (schemeEnd === -1) return uri;
const scheme = uri.substring(0, schemeEnd);
const seen = new Set<string>();
let match;
+
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
.trim()
);
-
const expandedParts = varNames
.map((name: string) => values[name] ?? '')
.filter((v: string) => v !== '');
*/
export function extractRootDomain(url: URL): string | null {
const hostname = url.hostname.toLowerCase();
+
if (!hostname || isIpAddress(hostname)) return null;
const parts = hostname.split('.');
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(TRAILING_SLASHES_REGEX, '');
-
// Aggressive: drop the port unconditionally. We only use this for
// equality checks between user-typed URLs and a hard-coded list of
// recommendations, where the port can never carry distinguishing
if (!ctx) {
reject(new Error('Failed to get 2D canvas context.'));
+
return;
}
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
+
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL(MimeTypeImage.PNG));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error converting WebP to PNG: ${message}`;
+
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
* the last segment); anything else glob-matches home-relative entries.
*/
-import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
-import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
+import { lastPathSegment } from './path-display';
import {
DRIVE_PREFIX_REGEX,
DRIVE_ROOT_REGEX,
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
} from '$lib/constants';
-import { lastPathSegment } from './path-display';
+import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
+import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
export interface GlobEntry {
path: string;
*/
function toPosixSeparators(query: string): string {
if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
+
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
}
export function rootPrefixLength(path: string): number {
const unc = path.match(UNC_ROOT_REGEX);
+
if (unc) return unc[0].length;
+
const drive = path.match(DRIVE_ROOT_REGEX);
+
if (drive) return drive[0].length;
+
return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0;
}
export function splitPathQuery(query: string): PathQuery | null {
const normalized = toPosixSeparators(query);
const rootLength = rootPrefixLength(normalized);
+
if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null;
// a root keeps its trailing separator so it stays absolute on its own
rootLength > 0
? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR
: HOME_TILDE;
-
const rest = normalized
.slice(rootLength > 0 ? rootLength : HOME_TILDE.length)
.replace(LEADING_SLASHES_REGEX, '')
.replace(TRAILING_SLASHES_REGEX, '');
-
const parentOf = (dirs: string) =>
rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs;
- if (!rest) return { parent: root, last: '' };
+ if (!rest) return { last: '', parent: root };
const idx = rest.lastIndexOf(PATH_SEPARATOR);
- if (idx === -1) return { parent: root, last: rest };
- return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
+
+ if (idx === -1) return { last: rest, parent: root };
+
+ return { last: rest.slice(idx + 1), parent: parentOf(rest.slice(0, idx)) };
}
export function buildCaseInsensitiveGlob(query: string): string {
let out = GLOB_WILDCARD;
+
for (const c of query) {
const lo = c.toLowerCase();
const up = c.toUpperCase();
+
if (lo !== up) out += GLOB_RANGE_OPEN + lo + up + GLOB_RANGE_CLOSE;
// glob metacharacters are escaped into a literal character class so a
// query like "a*b" matches a literal '*' instead of becoming "ab"
else if (GLOB_SPECIAL_CHARS.includes(c)) out += GLOB_RANGE_OPEN + c + GLOB_RANGE_CLOSE;
else out += c;
}
+
return out + GLOB_WILDCARD;
}
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(query);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
- return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
+
+ return { include, last: pathQuery?.last, maxDepth, path, rankQuery: pathQuery?.last ?? query };
}
const RANK_EXACT = 0;
function rankScore(path: string, query: string): number {
const name = lastPathSegment(path).toLowerCase();
const q = query.toLowerCase();
+
if (name === q) return RANK_EXACT;
+
if (name.startsWith(q)) return RANK_PREFIX;
+
if (name.includes(q)) return RANK_SUBSTRING;
+
return RANK_OTHER;
}
export function joinPath(base: string, rel: string): string {
if (!base) return rel;
+
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
}
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
- if (!query) return [{ text, match: false }];
+ if (!query) return [{ match: false, text }];
+
const segments: { text: string; match: boolean }[] = [];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
+
let i = 0;
+
while (i < text.length) {
const idx = lowerText.indexOf(lowerQuery, i);
+
if (idx < 0) {
- segments.push({ text: text.slice(i), match: false });
+ segments.push({ match: false, text: text.slice(i) });
+
break;
}
- if (idx > i) segments.push({ text: text.slice(i, idx), match: false });
- segments.push({ text: text.slice(idx, idx + query.length), match: true });
+
+ if (idx > i) segments.push({ match: false, text: text.slice(i, idx) });
+
+ segments.push({ match: true, text: text.slice(idx, idx + query.length) });
i = idx + query.length;
}
+
return segments;
}
<script lang="ts">
+ import { replaceState } from '$app/navigation';
+ import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
+ import { APP_NAME, URL_PARAMS } from '$lib/constants';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
- import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
+ import { modelOptions, modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { onMount } from 'svelte';
- import { page } from '$app/state';
- import { replaceState } from '$app/navigation';
- import { APP_NAME, URL_PARAMS } from '$lib/constants';
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
<script lang="ts">
import { goto, replaceState } from '$app/navigation';
- import { page } from '$app/state';
import { afterNavigate } from '$app/navigation';
+ import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
import { APP_NAME, ROUTES, URL_PARAMS } from '$lib/constants';
import { chatStore } from '$lib/stores/chat.svelte';
- import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
- import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
+ import { activeConversation, conversationsStore } from '$lib/stores/conversations.svelte';
+ import { modelOptions, modelsStore } from '$lib/stores/models.svelte';
let chatId = $derived(page.params.id);
let currentChatId: string | undefined = undefined;
*/
function clearUrlParams() {
const url = new URL(page.url);
+
url.searchParams.delete(URL_PARAMS.QUERY);
url.searchParams.delete(URL_PARAMS.MODEL);
replaceState(url.toString(), {});
// Handle model parameter - select model if provided
if (modelParam) {
const model = modelsStore.findModelByName(modelParam);
+
if (model) {
try {
await modelsStore.selectModelById(model.id);
console.error('Failed to select model:', error);
requestedModelName = modelParam;
showModelNotAvailable = true;
+
return;
}
} else {
// Model not found - show error dialog
requestedModelName = modelParam;
showModelNotAvailable = true;
+
return;
}
}
// Skip loading if this conversation is already active (e.g., just created)
if (activeConversation()?.id === chatId) {
void chatStore.discoverActiveStream(chatId);
+
if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
handleUrlParams();
}
+
return;
}
(async () => {
const success = await conversationsStore.loadConversation(chatId);
+
if (!success) {
await goto(ROUTES.START);
+
return;
}
+
chatStore.syncLoadingStateForChat(chatId);
// server probe (with localStorage fallback) and attach
await chatStore.discoverActiveStream(chatId);
// where the initial mount probe missed an active session
const onVisibility = () => {
if (document.visibilityState !== 'visible') return;
+
if (!chatId) return;
+
void chatStore.discoverActiveStream(chatId);
};
+
document.addEventListener('visibilitychange', onVisibility);
+
return () => document.removeEventListener('visibilitychange', onVisibility);
});
</script>
<script lang="ts">
- import { page } from '$app/stores';
import { goto } from '$app/navigation';
+ import { page } from '$app/stores';
import { ServerErrorSplash } from '$lib/components/app';
- import { ROUTES } from '$lib/constants/routes';
import { APP_NAME } from '$lib/constants';
+ import { ROUTES } from '$lib/constants/routes';
let error = $derived($page.error);
let status = $derived($page.status);
<script lang="ts">
import '../app.css';
- import { base } from '$app/paths';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { page } from '$app/state';
- import { untrack } from 'svelte';
- import { onMount } from 'svelte';
-
import { SidebarNavigation } from '$lib/components/app';
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
- import { pwaAssetsHead } from 'virtual:pwa-assets/head';
-
- import { chatStore } from '$lib/stores/chat.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
- import { ModeWatcher } from 'mode-watcher';
- import { ROUTES } from '$lib/constants/routes';
- import { RouterService } from '$lib/services/router.service';
- import { Toaster } from 'svelte-sonner';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
import { AUTHORIZATION_HEADER, BEARER_PREFIX, TOOLTIP_DELAY_DURATION } from '$lib/constants';
+ import { SETTINGS_KEYS } from '$lib/constants';
import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
+ import { ROUTES } from '$lib/constants/routes';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { usePwa } from '$lib/hooks/use-pwa.svelte';
+ import { RouterService } from '$lib/services/router.service';
+ import { buildInfoStore } from '$lib/stores/build-info.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
import { conversations } from '$lib/stores/conversations.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
import { theme } from '$lib/stores/theme.svelte';
- import { buildInfoStore } from '$lib/stores/build-info.svelte';
-
- import { SETTINGS_KEYS } from '$lib/constants';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import { ModeWatcher } from 'mode-watcher';
+ import { untrack } from 'svelte';
+ import { onMount } from 'svelte';
+ import { Toaster } from 'svelte-sonner';
+ import { pwaAssetsHead } from 'virtual:pwa-assets/head';
let { children } = $props();
let innerHeight = $state<number | undefined>();
const dark = theme.isSystemDark;
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
+
if (icoLink) {
icoLink.href = dark ? FAVICON_PATHS.ICO_DARK : FAVICON_PATHS.ICO_LIGHT;
}
let svgLink = document.querySelector(FAVICON_SELECTORS.SVG_ANY) as HTMLLinkElement | null;
+
if (svgLink) {
svgLink.href = dark ? FAVICON_PATHS.SVG_DARK : FAVICON_PATHS.SVG_LIGHT;
}
// Global keyboard shortcuts
const { handleKeydown } = useKeyboardShortcuts({
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
- navigateToPrevConversation: () => navigateToConversation(-1),
- navigateToNextConversation: () => navigateToConversation(1)
+ navigateToNextConversation: () => navigateToConversation(1),
+ navigateToPrevConversation: () => navigateToConversation(-1)
});
function checkApiKey() {
page.status !== 403
) {
const headers: Record<string, string> = {
- 'Content-Type': 'application/json',
- [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey.trim()}`
+ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey.trim()}`,
+ 'Content-Type': 'application/json'
};
fetch(`${base}/props`, { headers })
// or ended while it was hidden. snapshot only, no polling
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
+
void chatStore.syncRemoteRunningStreams();
}
// Live model status and load progress via the /models/sse feed (router mode)
$effect(() => {
if (!browser) return;
+
if (!isRouterMode()) return;
untrack(() => {
if (!browser) return;
const mcpServers = mcpStore.getServers();
-
const serversWithUrls = mcpServers.filter((s) => s.url.trim());
if (serversWithUrls.length > 0) {
<script lang="ts">
+ import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
- import { browser } from '$app/environment';
-
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
- import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversations, conversationsStore } from '$lib/stores/conversations.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
let searchQuery = $state('');
let filteredConversations = $derived.by(() => {
const query = searchQuery.trim().toLowerCase();
+
if (query.length === 0) return [];
+
return conversations().filter((c) => c.name.toLowerCase().includes(query));
});
async function handleEditConversation(id: string) {
const conversation = conversations().find((c) => c.id === id);
+
if (!conversation) return;
const newName = window.prompt('Rename conversation', conversation.name);
+
if (newName && newName.trim()) {
await conversationsStore.updateConversationName(id, newName.trim());
}
async function handleDeleteConversation(id: string) {
const conversation = conversations().find((c) => c.id === id);
+
if (!conversation) return;
const confirmed = window.confirm(
`Delete "${conversation.name}"? This action cannot be undone.`
);
+
if (!confirmed) return;
await conversationsStore.deleteConversation(id, { deleteWithForks: false });
<script lang="ts">
import { X } from '@lucide/svelte';
- import { goto } from '$app/navigation';
import { browser } from '$app/environment';
+ import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
$effect(() => {
const currentId = page.route.id;
+
return () => {
previousRouteId = currentId;
};
function handleClose() {
const prevIsSettings = previousRouteId?.startsWith('/settings');
+
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
<script lang="ts">
- import { SettingsChat } from '$lib/components/app/settings';
- import { page } from '$app/state';
import { afterNavigate, replaceState } from '$app/navigation';
- import { RouterService } from '$lib/services';
+ import { page } from '$app/state';
+ import { SettingsChat } from '$lib/components/app/settings';
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
+ import { RouterService } from '$lib/services';
afterNavigate(() => {
if (!page.params.section) {
-import { mdsvex } from 'mdsvex';
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+import { mdsvex } from 'mdsvex';
// CMake sets LLAMA_UI_OUT_DIR to the staging dir under the build tree; manual
// `npm run build` runs without the env var default to ./dist.
const outDir = process.env.LLAMA_UI_OUT_DIR ?? './dist';
-
/** @type {import('@sveltejs/kit').Config} */
const config = {
- // Consult https://svelte.dev/docs/kit/integrations
- // for more information about preprocessors
- preprocess: [vitePreprocess(), mdsvex()],
+ extensions: ['.svelte', '.svx'],
kit: {
- paths: {
- relative: true
- },
- router: { type: 'hash' },
adapter: adapter({
- pages: outDir,
assets: outDir,
fallback: 'index.html',
+ pages: outDir,
precompress: false,
strict: true
}),
+ alias: {
+ $styles: 'src/styles'
+ },
output: {
bundleStrategy: 'single'
},
- alias: {
- $styles: 'src/styles'
- }
+ paths: {
+ relative: true
+ },
+ router: { type: 'hash' }
},
- extensions: ['.svelte', '.svx']
+ // Consult https://svelte.dev/docs/kit/integrations
+ // for more information about preprocessors
+ preprocess: [vitePreprocess(), mdsvex()]
};
export default config;
//
// Run: npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
-import { describe, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
+import { perfState } from './components/agentic-perf-state.svelte';
import AgenticPerfWrapper from './components/AgenticPerfWrapper.svelte';
import ChatMessagesPerfWrapper from './components/ChatMessagesPerfWrapper.svelte';
-import { perfState } from './components/agentic-perf-state.svelte';
+import { MessageRole } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { DatabaseMessage } from '$lib/types';
-import { MessageRole } from '$lib/enums';
+import { tick } from 'svelte';
+import { describe, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
// --- fixture construction -------------------------------------------------
}
const DEFAULTS: FixtureOpts = {
- priorToolCalls: 0,
- toolResultBytes: 1024,
editFileEdits: 0,
openCodeFence: false,
- paragraphEvery: 0
+ paragraphEvery: 0,
+ priorToolCalls: 0,
+ toolResultBytes: 1024
};
function blob(bytes: number, seed: string): string {
const line = `${seed} output line with some representative width to it`;
const n = Math.max(1, Math.ceil(bytes / (line.length + 1)));
const out: string[] = [];
+
for (let i = 0; i < n; i++) out.push(`${line} ${i}`);
+
return out.join('\n');
}
function diffLines(n: number, seed: string): string {
const out: string[] = [];
+
for (let i = 0; i < n; i++) out.push(`${seed} line ${i} const value_${i} = compute(${i});`);
+
return out.join('\n');
}
function baseMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
return {
- id: `m${msgSeq++}`,
- convId: 'perf-conv',
- type: 'text',
- timestamp: 0,
- role: MessageRole.ASSISTANT,
+ children: [],
content: '',
+ convId: 'perf-conv',
+ id: `m${msgSeq++}`,
parent: null,
- children: [],
+ role: MessageRole.ASSISTANT,
+ timestamp: 0,
+ type: 'text',
...overrides
} as DatabaseMessage;
}
for (let i = 0; i < opts.priorToolCalls; i++) {
const id = `call_${i}`;
+
toolCalls.push({
- id,
- type: 'function',
function: {
- name: 'exec_shell_command',
- arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` })
- }
+ arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
+ name: 'exec_shell_command'
+ },
+ id,
+ type: 'function'
});
toolMessages.push(
baseMessage({
+ content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`,
role: MessageRole.TOOL,
- toolCallId: id,
- content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`
+ toolCallId: id
})
);
}
for (let i = 0; i < opts.editFileEdits; i++) {
const id = `edit_${i}`;
+
toolCalls.push({
- id,
- type: 'function',
function: {
- name: 'edit_file',
arguments: JSON.stringify({
- path: `/src/file_${i}.ts`,
- edits: [{ old_text: diffLines(400, 'old'), new_text: diffLines(400, 'new') }]
- })
- }
+ edits: [{ new_text: diffLines(400, 'new'), old_text: diffLines(400, 'old') }],
+ path: `/src/file_${i}.ts`
+ }),
+ name: 'edit_file'
+ },
+ id,
+ type: 'function'
});
toolMessages.push(
baseMessage({
+ content: JSON.stringify({ edits_applied: 1, result: 'ok' }),
role: MessageRole.TOOL,
- toolCallId: id,
- content: JSON.stringify({ result: 'ok', edits_applied: 1 })
+ toolCallId: id
})
);
}
const message = baseMessage({
- toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined,
- content: ''
+ content: '',
+ toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined
});
return { message, toolMessages };
await tick();
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
+
let accumulated = opts.openCodeFence ? '```notalanguage\n' : '';
- const durations: number[] = [];
+ const durations: number[] = [];
const wallStart = performance.now();
for (let i = 0; i < tokens; i++) {
accumulated += CHUNK;
+
if (opts.paragraphEvery > 0 && (i + 1) % opts.paragraphEvery === 0) {
accumulated += '\n\n';
}
const t0 = performance.now();
+
// Mirrors updateMessageAtIndex: a brand-new object identity per chunk.
perfState.message = { ...perfState.message!, content: accumulated };
await tick();
results.push({
label,
- tokens,
+ max: durations[durations.length - 1],
mean: total / durations.length,
p95: durations[Math.floor(durations.length * 0.95)],
- max: durations[durations.length - 1],
+ tokens,
total,
wall
});
function report() {
const pad = (s: string, n: number) => s.padEnd(n);
const num = (n: number) => n.toFixed(2).padStart(8);
-
const header = `${pad('fixture', 40)}${pad('tok', 5)}${'mean'.padStart(8)}${'p95'.padStart(8)}${'max'.padStart(8)}${'sync'.padStart(9)}${'wall'.padStart(9)}`;
const lines = [
'',
agentic = false
) {
const history: DatabaseMessage[] = [];
+
for (let i = 0; i < priorMessages; i++) {
const isAssistant = i % 2 !== 0;
if (isAssistant && agentic) {
const id = `prior_call_${i}`;
+
history.push(
baseMessage({
- role: MessageRole.ASSISTANT,
content: `Message ${i}`,
+ role: MessageRole.ASSISTANT,
toolCalls: JSON.stringify([
{
- id,
- type: 'function',
function: {
- name: 'exec_shell_command',
- arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` })
- }
+ arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
+ name: 'exec_shell_command'
+ },
+ id,
+ type: 'function'
}
])
})
);
history.push(
baseMessage({
+ content: `${blob(1024, `r${i}`)}\n[exit code: 0]`,
role: MessageRole.TOOL,
- toolCallId: id,
- content: `${blob(1024, `r${i}`)}\n[exit code: 0]`
+ toolCallId: id
})
);
+
continue;
}
history.push(
baseMessage({
- role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER,
- content: `Message ${i}: ${blob(512, `m${i}`)}`
+ content: `Message ${i}: ${blob(512, `m${i}`)}`,
+ role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER
})
);
}
- const streaming = baseMessage({ role: MessageRole.ASSISTANT, content: '' });
+ const streaming = baseMessage({ content: '', role: MessageRole.ASSISTANT });
+
history.push(streaming);
conversationsStore.activeMessages = history;
const { unmount } = render(ChatMessagesPerfWrapper);
+
await tick();
const idx = conversationsStore.findMessageIndex(streaming.id);
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
+
let accumulated = '';
+
const durations: number[] = [];
const wallStart = performance.now();
accumulated += CHUNK;
const t0 = performance.now();
+
// The real path: chat.svelte.ts -> conversations.svelte.ts.
conversationsStore.updateMessageAtIndex(idx, { content: accumulated });
await tick();
results.push({
label,
- tokens,
+ max: durations[durations.length - 1],
mean: total / durations.length,
p95: durations[Math.floor(durations.length * 0.95)],
- max: durations[durations.length - 1],
+ tokens,
total,
wall
});
-import { beforeEach, describe, expect, it } from 'vitest';
-import { validateApiKey } from '$lib/utils/api-key-validation';
-import { settingsStore } from '$lib/stores/settings.svelte';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { settingsStore } from '$lib/stores/settings.svelte';
+import { validateApiKey } from '$lib/utils/api-key-validation';
+import { beforeEach, describe, expect, it } from 'vitest';
function fakeFetch(status: number, capture: { auth?: string | null } = {}) {
return (async (_url: RequestInfo | URL, init?: RequestInit) => {
capture.auth = (init?.headers as Record<string, string>)?.['Authorization'] ?? null;
+
return new Response(status === 200 ? '{}' : 'Unauthorized', { status });
}) as typeof globalThis.fetch;
}
it('valid stored key: passes and sends the bearer header', async () => {
settingsStore.updateConfig('apiKey', 'sk-good');
const capture: { auth?: string | null } = {};
+
await expect(validateApiKey(fakeFetch(200, capture))).resolves.toBeUndefined();
expect(capture.auth).toBe('Bearer sk-good');
});
-import { describe, expect, it } from 'vitest';
import { capImageDataURLSize } from '$lib/utils/cap-img-size';
import { getJpegOrientationFromDataURL } from '$lib/utils/jpeg-orientation';
+import { describe, expect, it } from 'vitest';
// Real 64x32 jpegs generated with Pillow, quality 90. The upright picture is
// four solid quadrants: top left red, top right green, bottom left blue,
const EXIF6 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDCooor+Tz+Hz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/Keiiiv7CP72PAqKKK/3GP8AHQ99ooor/Dk/2LPAqKKK/wBxj/HQ/9k=`;
const EXIF8 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAgAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD896KKK/1TPhz32iiiv8OT/Ys8Cooor/cY/wAdD32iiiv8OT/Ys/Viiiiv49P4JPhWiiiv6FP8wj7qooor+ej/AE9PhWiiiv6FP8wj/9k=`;
const NOEXIF = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
-
const RED: Rgb = [255, 0, 0];
const GREEN: Rgb = [0, 200, 0];
const BLUE: Rgb = [0, 0, 255];
const YELLOW: Rgb = [255, 220, 0];
-
// Wide tolerance per channel, jpeg compression shifts solid colors a bit
const COLOR_TOLERANCE = 70;
-
// 0.000512 megapixels is 512 pixels, a quarter of the area of the 2048 pixel fixtures
const QUARTER_AREA_MEGAPIXELS = 0.000512;
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
+
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to decode image.'));
img.src = dataUrl;
async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
const img = await loadImage(dataUrl);
const canvas = document.createElement('canvas');
+
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d')!;
+
ctx.drawImage(img, 0, 0);
const points = [
[0.25, 0.25],
[0.25, 0.75],
[0.75, 0.75]
];
+
return points.map(([fx, fy]) => {
const d = ctx.getImageData(
Math.floor(canvas.width * fx),
1,
1
).data;
+
return [d[0], d[1], d[2]];
});
}
function expectUpright(colors: Rgb[]) {
const targets = [RED, GREEN, BLUE, YELLOW];
+
for (let i = 0; i < 4; i++) {
for (let c = 0; c < 3; c++) {
expect(Math.abs(colors[i][c] - targets[i][c])).toBeLessThan(COLOR_TOLERANCE);
// serialization must fold those back into `\n` so the emitted value never
// diverges from what is on screen.
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
const SOURCE = 'see [docs](file:///a/b) here';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
+
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
return el;
}
function setCaret(node: Node, offset: number) {
const range = document.createRange();
+
range.setStart(node, offset);
range.setEnd(node, offset);
const selection = window.getSelection();
+
if (!selection) throw new Error('no selection');
+
selection.removeAllRanges();
selection.addRange(range);
}
describe('ChatFormContenteditable browser newline shapes', () => {
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
+
div.textContent = 'second line';
root.appendChild(div);
fireInput(root);
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+
await tick();
const root = editableIn(screen.container);
const first = document.createElement('div');
+
while (root.firstChild) first.appendChild(root.firstChild);
const second = document.createElement('div');
+
second.textContent = 'second line';
root.appendChild(first);
root.appendChild(second);
it('serializes a <br> as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
+
await tick();
const root = editableIn(screen.container);
+
root.appendChild(document.createElement('br'));
root.appendChild(document.createTextNode('second line'));
fireInput(root);
it('ignores a trailing <br> (browser caret placeholder)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
+
root.appendChild(document.createElement('br'));
fireInput(root);
await tick();
it('serializes one newline per empty-line <div><br></div>', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
+
for (let i = 0; i < 2; i++) {
const div = document.createElement('div');
+
div.appendChild(document.createElement('br'));
root.appendChild(div);
}
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
+
div.appendChild(document.createElement('br'));
root.replaceChildren(div);
fireInput(root);
it('maps the caret across block boundaries in both directions', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
+
await tick();
// Rebuild into the Chromium block shape; the source is unchanged,
// so no re-render fires.
const root = editableIn(screen.container);
const div = document.createElement('div');
+
div.textContent = 'def';
root.replaceChildren(document.createTextNode('abc'), div);
fireInput(root);
expect(screen.component.getValue()).toBe('abc\ndef');
const divText = div.firstChild;
+
if (!divText) throw new Error('div text missing');
setCaret(divText, 2);
screen.component.setCaretOffset(6);
const selection = window.getSelection();
+
expect(selection?.anchorNode).toBe(divText);
expect(selection?.anchorOffset).toBe(2);
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
// keyboard trap), matching the plain textarea.
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
const SOURCE = 'see [docs](file:///a/b)';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
+
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
return el;
}
function keydown(root: HTMLElement, init: KeyboardEventInit) {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
+
root.dispatchEvent(event);
+
return event;
}
describe('ChatFormContenteditable undo/redo', () => {
it('undoes and redoes an edit across a badge-containing buffer', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+
await tick();
const root = editableIn(screen.container);
+
type(root, ' more');
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
- const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
+ const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
+
await tick();
expect(undoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(SOURCE);
- const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
+ const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
+
await tick();
expect(redoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
it('redoes with Ctrl+Y as well', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+
await tick();
const root = editableIn(screen.container);
+
type(root, ' more');
await tick();
keydown(root, { key: 'z', metaKey: true });
await tick();
expect(screen.component.getValue()).toBe(SOURCE);
- keydown(root, { key: 'y', ctrlKey: true });
+ keydown(root, { ctrlKey: true, key: 'y' });
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
});
it('coalesces a typing burst into one undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
+
type(root, 'd');
type(root, 'e');
await tick();
expect(screen.component.getValue()).toBe('abcde');
- keydown(root, { key: 'z', ctrlKey: true });
+ keydown(root, { ctrlKey: true, key: 'z' });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('keeps a newline as its own undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
+
type(root, 'd');
type(root, '\n', 'insertLineBreak');
await tick();
expect(screen.component.getValue()).toBe('abcd\n');
- keydown(root, { key: 'z', ctrlKey: true });
+ keydown(root, { ctrlKey: true, key: 'z' });
await tick();
expect(screen.component.getValue()).toBe('abcd');
- keydown(root, { key: 'z', ctrlKey: true });
+ keydown(root, { ctrlKey: true, key: 'z' });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('is a no-op when there is nothing to undo', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
- const event = keydown(root, { key: 'z', ctrlKey: true });
+ const event = keydown(root, { ctrlKey: true, key: 'z' });
+
await tick();
expect(event.defaultPrevented).toBe(true);
it('abandons the redo branch after a fresh edit', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
+
await tick();
const root = editableIn(screen.container);
+
type(root, 'd');
await tick();
- keydown(root, { key: 'z', ctrlKey: true });
+ keydown(root, { ctrlKey: true, key: 'z' });
await tick();
expect(screen.component.getValue()).toBe('abc');
type(root, 'e');
await tick();
- keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
+ keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
await tick();
expect(screen.component.getValue()).toBe('abce');
});
describe('ChatFormContenteditable Tab key', () => {
it('does not trap Tab (focus can leave the editable)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
+
await tick();
const root = editableIn(screen.container);
// contributes its full `[name](file://...)` link) and pasting such
// markdown re-renders the badges.
-import { describe, it, expect, vi } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { userEvent } from 'vitest/browser';
-import { tick } from 'svelte';
-import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
+import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
+import { tick } from 'svelte';
+import { describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
const SOURCE = 'hello [docs](file:///a/b) world';
const BADGE_SELECTOR = '[data-mention-badge="true"]';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
+
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
return el;
}
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
const range = document.createRange();
+
place(range, root);
const selection = window.getSelection();
+
if (!selection) throw new Error('no selection');
+
selection.removeAllRanges();
selection.addRange(range);
}
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
const data = new DataTransfer();
+
if (text) data.setData('text/plain', text);
- const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true });
- return { event, data };
+
+ const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
+
+ return { data, event };
}
describe('ChatFormContenteditable clipboard', () => {
it('copy exposes the markdown source of the selection', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
+
await tick();
const root = editableIn(container);
+
setSelection(root, (range) => range.selectNodeContents(root));
- const { event, data } = clipboardEvent('copy');
+ const { data, event } = clipboardEvent('copy');
+
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
it('cut exposes the markdown source and removes the slice', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
+
await tick();
const root = editableIn(container);
+
setSelection(root, (range) => {
const badge = root.querySelector(BADGE_SELECTOR);
+
if (!badge) throw new Error('badge not rendered');
+
range.setStartBefore(badge);
range.setEndAfter(badge);
});
- const { event, data } = clipboardEvent('cut');
+ const { data, event } = clipboardEvent('cut');
+
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
it('paste of markdown mention links re-renders badges', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
});
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
+
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const badge = root.querySelector(BADGE_SELECTOR);
+
expect(badge).not.toBeNull();
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
expect(root.textContent).toContain('world');
it('paste without mention links keeps the DOM untouched', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const firstChild = root.firstChild;
-
const { event } = clipboardEvent('paste', 'plain text');
+
root.dispatchEvent(event);
await tick();
describe('ChatFormContenteditable code spans', () => {
it('renders inline code from the initial value', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
+
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="inline"]');
+
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
});
it('renders a fenced code block with a language', async () => {
const source = 'before\n```js\nconst a = 1;\n```\nafter';
const { container } = render(ChatFormContenteditable, { value: source });
+
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
+
expect(code).not.toBeNull();
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
});
it('copy exposes the markdown source of a selection spanning code', async () => {
const source = 'run `npm test` now';
const { container } = render(ChatFormContenteditable, { value: source });
+
await tick();
const root = editableIn(container);
+
setSelection(root, (range) => range.selectNodeContents(root));
- const { event, data } = clipboardEvent('copy');
+ const { data, event } = clipboardEvent('copy');
+
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
it('paste of a code span renders the styled element', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run ' });
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
});
const { event } = clipboardEvent('paste', '`npm test` now');
+
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const code = root.querySelector('code[data-code-token="inline"]');
+
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
expect(root.textContent).toContain('now');
it('highlights a fenced block content and stays byte-exact', async () => {
const source = '```js\nconst a = 1;\n```';
const { container } = render(ChatFormContenteditable, { value: source });
+
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
+
expect(code).not.toBeNull();
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
expect(code!.textContent).toBe(source);
it('does not highlight inline code', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
+
await tick();
const root = editableIn(container);
+
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
});
});
function blockIn(root: HTMLElement): HTMLElement {
const el = root.querySelector(BLOCK_SELECTOR);
+
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
+
return el;
}
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
const code = blockIn(root);
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
+
let target: Node | null = null;
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
target = where === 'start' ? (target ?? n) : n;
}
+
if (!target) throw new Error('no text inside code block');
+
setSelection(root, (range) => {
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
range.collapse(true);
function caretContainer(): Node {
const selection = window.getSelection();
+
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
+
return selection.getRangeAt(0).startContainer;
}
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
// no permanent empty line above a leading block
expect(root.firstChild).toBe(blockIn(root));
expect(root.lastChild?.nodeName).toBe('BR');
setSelection(root, (range) => range.selectNodeContents(root));
- const { event, data } = clipboardEvent('copy');
+ const { data, event } = clipboardEvent('copy');
+
root.dispatchEvent(event);
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
it('escapes a trailing code block with ArrowDown and types after it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
it('escapes a leading code block with ArrowUp and types before it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'start');
it('escapes a leading code block with ArrowLeft from its first character', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'start');
it('removes the transient leading hatch when the caret moves back into the block', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'start');
it('extends the selection out of the block with Shift+ArrowDown', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
const selection = window.getSelection();
+
expect(selection).not.toBeNull();
expect(selection!.isCollapsed).toBe(false);
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
it('line-separates text typed right after the closing fence', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
it('does not double the newline when Shift+Enter already added one', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
// post-break DOM some browsers produce: the inserted newline plus
range.collapse(true);
});
- root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
+ root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
await tick();
const selection = window.getSelection();
+
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
// post-break DOM some browsers produce: a lone trailing \n (or a
range.collapse(true);
});
- root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
+ root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
await tick();
const selection = window.getSelection();
+
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
const text = root.childNodes[1];
+
range.setStart(text, (text.textContent ?? '').length);
range.collapse(true);
});
await tick();
const selection = window.getSelection();
+
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
it('lets Backspace at the text start move into the block without a source fight', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
it('lets forward Delete eat the text after a block normally', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
placeCaretInBlock(root, 'end');
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\nhello'
});
+
await tick();
const root = editableIn(container);
+
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
});
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\n\nhello'
});
+
await tick();
const root = editableIn(container);
+
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
});
it('re-highlights while typing inside a block and keeps the caret', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
+
await tick();
const root = editableIn(container);
+
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
+
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await tick();
const code = blockIn(root);
+
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
expect(code.querySelector('.hljs-number')).not.toBeNull();
const selection = window.getSelection();
+
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
it('adds a line instead of submitting on plain Enter inside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: BLOCK_SOURCE,
- onKeydown
+ onKeydown,
+ value: BLOCK_SOURCE
});
+
await tick();
const root = editableIn(container);
+
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
+
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
const code = root.querySelector('code[data-code-token="block"]');
const selection = window.getSelection();
+
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: '```js\nconst a = 1;',
- onKeydown
+ onKeydown,
+ value: '```js\nconst a = 1;'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
+
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: BLOCK_SOURCE + '\nafter',
- onKeydown
+ onKeydown,
+ value: BLOCK_SOURCE + '\nafter'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
it('forwards plain Enter on the trailing hatch line after a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: BLOCK_SOURCE,
- onKeydown
+ onKeydown,
+ value: BLOCK_SOURCE
});
+
await tick();
const root = editableIn(container);
+
root.focus();
// root-level caret between the block and its trailing br hatch
setSelection(root, (range) => {
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: BLOCK_SOURCE,
- onKeydown
+ onKeydown,
+ value: BLOCK_SOURCE
});
+
await tick();
const root = editableIn(container);
+
root.focus();
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
+
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Control>}{Enter}{/Control}');
expect(onKeydown).toHaveBeenCalledWith(
- expect.objectContaining({ key: 'Enter', ctrlKey: true })
+ expect.objectContaining({ ctrlKey: true, key: 'Enter' })
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
});
it('forwards Enter inside an inline code span', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
- value: 'run `npm test` now',
- onKeydown
+ onKeydown,
+ value: 'run `npm test` now'
});
+
await tick();
const root = editableIn(container);
+
root.focus();
const code = root.querySelector('code[data-code-token="inline"]')!;
+
setSelection(root, (range) => {
range.setStart(code.firstChild!, 3);
range.collapse(true);
// The textarea path is covered here end-to-end (the contenteditable
// consumes the same case locally; see chat-form-contenteditable).
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { userEvent } from 'vitest/browser';
-import { tick } from 'svelte';
+import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { settingsStore } from '$lib/stores/settings.svelte';
-import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { tick } from 'svelte';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
const el = container.querySelector('textarea');
+
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
+
return el;
}
it('adds a line after a still-open fence instead of submitting', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
+
await tick();
const textarea = textareaIn(container);
+
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
it('keeps adding lines while the block stays open', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
+
await tick();
const textarea = textareaIn(container);
+
await userEvent.click(textarea);
await userEvent.keyboard('```js');
await tick();
it('submits when the caret is before the opening fence', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
+
await tick();
const textarea = textareaIn(container);
+
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
it('submits on Enter outside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
+
await tick();
const textarea = textareaIn(container);
+
await userEvent.click(textarea);
await userEvent.keyboard('hello');
await tick();
it('submits on Ctrl+Enter even inside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
+
await tick();
const textarea = textareaIn(container);
+
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
// it, the picker still opens but explains why instead of firing searches
// that would only fail with "Search failed".
-import { describe, it, expect, afterEach } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
-import { BuiltInTool } from '$lib/enums';
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
+import { BuiltInTool } from '$lib/enums';
+import { toolsStore } from '$lib/stores/tools.svelte';
import type { OpenAIToolDefinition } from '$lib/types';
+import { tick } from 'svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
- type: 'function',
- function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
+ function: { description: '', name: BuiltInTool.FILE_GLOB_SEARCH, parameters: {} },
+ type: 'function'
};
-
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
// The store keeps its builtin tool list private; tests inject it through
function renderPicker() {
return render(ChatFormMentionPicker, {
isOpen: true,
- query: 'main',
onClose: () => {},
- onSelect: () => {}
+ onSelect: () => {},
+ query: 'main'
});
}
// Typing `/model is broken` is prose until the command is picked - the
// buffer must survive; only an actual selection consumes the token.
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
describe('slash command dispatch', () => {
it('does not dispatch or clear the buffer when a space follows the name', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
screen.component.type('/model is broken');
await tick();
const pickers = screen.component.getPickers();
+
expect(screen.component.getValue()).toBe('/model is broken');
expect(screen.component.getCalls()).not.toContain('openModelSelector');
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
it('dispatches /model on explicit selection and consumes the token', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
screen.component.type('/model is broken');
const pickers = screen.component.getPickers();
const model = pickers.availableCommands.find((c) => c.name === 'model');
+
if (!model) throw new Error('model command missing');
+
pickers.handleCommandSelect(model);
await tick();
it('seeds the prompt picker search from the token args on selection', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
screen.component.type('/prompt weather');
await tick();
const pickers = screen.component.getPickers();
+
expect(pickers.isPromptPickerOpen).toBe(false);
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
+
if (!prompt) throw new Error('prompt command missing');
+
pickers.handleCommandSelect(prompt);
await tick();
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
screen.component.type('/cw docs');
await tick();
const pickers = screen.component.getPickers();
+
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+
if (!cwd) throw new Error('cwd command missing');
+
pickers.handleCommandSelect(cwd);
await tick();
it('syncs the /cwd token into the picker search while the picker is open', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
const pickers = screen.component.getPickers();
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+
if (!cwd) throw new Error('cwd command missing');
screen.component.type('/cwd docs');
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
const screen = render(ChatFormPickersHarness);
+
await tick();
const pickers = screen.component.getPickers();
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+
if (!cwd) throw new Error('cwd command missing');
screen.component.type('/cwd docs');
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
// not rendered, and opening still mounts the body.
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import CollapsibleLazyBodyHarness from './components/CollapsibleLazyBodyHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
const MARKER = 'collapsible-body-marker';
describe('collapsible wrappers render their body lazily', () => {
for (const variant of ['content', 'terminal'] as const) {
it(`${variant}: body is absent while closed and present once open`, async () => {
- const screen = render(CollapsibleLazyBodyHarness, { variant, open: false });
+ const screen = render(CollapsibleLazyBodyHarness, { open: false, variant });
+
await tick();
expect(document.body.textContent).not.toContain(MARKER);
- await screen.rerender({ variant, open: true });
+ await screen.rerender({ open: true, variant });
await tick();
expect(document.body.textContent).toContain(MARKER);
// And it unmounts again on close, so a collapsed block stops costing
// anything during streaming.
- await screen.rerender({ variant, open: false });
+ await screen.rerender({ open: false, variant });
await tick();
expect(document.body.textContent).not.toContain(MARKER);
<script lang="ts">
- import * as Tooltip from '$lib/components/ui/tooltip';
- import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
import { perfState } from './agentic-perf-state.svelte';
+ import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
</script>
<Tooltip.Provider>
<script lang="ts">
- import { untrack } from 'svelte';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
+ import { untrack } from 'svelte';
interface Props {
value?: string;
const calls: string[] = [];
const pickers = useChatFormPickers({
- getValue: () => value,
- setValue: (v) => {
- value = v;
- calls.push(`setValue:${v}`);
- },
- getCaretOffset: () => caretOffset,
- setCaretOffset: (o) => {
- caretOffset = o;
- },
focusInput: () => {},
- getShowModelSelector: () => true,
- hasPrompts: () => true,
- hasCwdTools: () => true,
+ getCaretOffset: () => caretOffset,
getCwd: () => null,
+ getPickersRef: () => undefined,
getServerHome: () => null,
+ getShowModelSelector: () => true,
+ getValue: () => value,
+ hasCwdTools: () => true,
+ hasPrompts: () => true,
openModelSelector: () => {
calls.push('openModelSelector');
},
- getPickersRef: () => undefined
+ setCaretOffset: (o) => {
+ caretOffset = o;
+ },
+ setValue: (v) => {
+ value = v;
+ calls.push(`setValue:${v}`);
+ }
});
// Simulate the user typing: update the buffer and run the input flow.
<script lang="ts">
- import * as Tooltip from '$lib/components/ui/tooltip';
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
let { onSubmit }: { onSubmit?: () => void } = $props();
// Mounts the real ChatMessages list against the real conversations store, so
// the harness exercises `displayMessages` (which rebuilds every message's
// toolMessages array) rather than a single message subtree.
- import * as Tooltip from '$lib/components/ui/tooltip';
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
import { conversationsStore } from '$lib/stores/conversations.svelte';
</script>
open: boolean;
}
- let { variant, open }: Props = $props();
+ let { open, variant }: Props = $props();
</script>
{#if variant === 'content'}
<script lang="ts">
- import { untrack } from 'svelte';
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
+ import { untrack } from 'svelte';
interface Props {
headers?: string;
<script lang="ts">
- import * as Tooltip from '$lib/components/ui/tooltip';
import Page from '../../../src/routes/(chat)/+page.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
</script>
<!--
toolMessages: DatabaseMessage[];
isStreaming: boolean;
}>({
+ isStreaming: true,
message: null,
- toolMessages: [],
- isStreaming: true
+ toolMessages: []
});
-import { afterEach, describe, expect, it } from 'vitest';
-import { DatabaseService } from '$lib/services/database.service';
import { MessageRole, MessageType } from '$lib/enums';
+import { DatabaseService } from '$lib/services/database.service';
import type { ExportedConversation } from '$lib/types/database';
+import { afterEach, describe, expect, it } from 'vitest';
function makeSession(id: string): ExportedConversation {
return {
- conv: { id, currNode: `${id}-msg`, lastModified: 0, name: `Chat ${id}` },
+ conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
messages: [
{
- id: `${id}-msg`,
- convId: id,
- type: MessageType.TEXT,
- timestamp: 0,
- role: MessageRole.USER,
+ children: [],
content: `hello from ${id}`,
+ convId: id,
+ id: `${id}-msg`,
parent: null,
- children: []
+ role: MessageRole.USER,
+ timestamp: 0,
+ type: MessageType.TEXT
}
]
} as unknown as ExportedConversation;
afterEach(async () => {
const conversations = await DatabaseService.getAllConversations();
+
await DatabaseService.bulkDeleteConversations(conversations.map((conv) => conv.id));
});
-import { beforeEach, describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
import { McpServerForm } from '$lib/components/app/mcp';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
describe('mcp server display name', () => {
beforeEach(() => {
it('custom display name wins over the url fallback', () => {
const server = mcpStore.addServer({
+ displayName: 'My Tools',
enabled: false,
- url: 'https://mcp.example.com/a',
- displayName: 'My Tools'
+ url: 'https://mcp.example.com/a'
});
+
expect(mcpStore.getServerLabel(server)).toBe('My Tools');
});
it('without a custom name the url is the label', () => {
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/a' });
+
expect(mcpStore.getServerLabel(server)).toBe('https://mcp.example.com/a');
});
it('identical labels get positional suffixes', () => {
const a = mcpStore.addServer({
+ displayName: 'GitHub',
enabled: false,
- url: 'https://mcp.example.com/a',
- displayName: 'GitHub'
+ url: 'https://mcp.example.com/a'
});
const b = mcpStore.addServer({
+ displayName: 'GitHub',
enabled: false,
- url: 'https://mcp.example.com/b',
- displayName: 'GitHub'
+ url: 'https://mcp.example.com/b'
});
+
expect(mcpStore.getServerLabel(a)).toBe('GitHub (1)');
expect(mcpStore.getServerLabel(b)).toBe('GitHub (2)');
});
it('renaming one twin dissolves the suffixes', () => {
const a = mcpStore.addServer({
+ displayName: 'GitHub',
enabled: false,
- url: 'https://mcp.example.com/a',
- displayName: 'GitHub'
+ url: 'https://mcp.example.com/a'
});
const b = mcpStore.addServer({
+ displayName: 'GitHub',
enabled: false,
- url: 'https://mcp.example.com/b',
- displayName: 'GitHub'
+ url: 'https://mcp.example.com/b'
});
+
mcpStore.updateServer(b.id, { displayName: 'GitHub Work' });
expect(mcpStore.getServerLabel(a)).toBe('GitHub');
expect(mcpStore.getServerLabel(mcpStore.getServerById(b.id)!)).toBe('GitHub Work');
it('the form exposes an editable display name field', async () => {
let captured = '';
+
const screen = await render(McpServerForm, {
- url: 'https://mcp.example.com/a',
headers: '',
name: '',
- onUrlChange: () => {},
onHeadersChange: () => {},
- onNameChange: (v: string) => (captured = v)
+ onNameChange: (v: string) => (captured = v),
+ onUrlChange: () => {},
+ url: 'https://mcp.example.com/a'
});
-
const input = screen.getByLabelText('Display name');
+
await expect.element(input).toBeVisible();
await input.fill('My Custom Server');
expect(captured).toBe('My Custom Server');
+import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
-import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
const AUTHORIZATION_HEADER = 'Authorization';
const BEARER_PREFIX = 'Bearer ';
await screen.getByRole('switch', { name: /authorization/i }).click();
const token = 'super-secret';
+
await bearerInput(screen).fill(token);
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
+
await expect
.element(capturedHeaders(screen))
.toHaveAttribute('data-captured-headers', expected);
it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
const existing = JSON.stringify({
- 'X-Trace-Id': 'abc',
- [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`
+ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`,
+ 'X-Trace-Id': 'abc'
});
-
const screen = await render(McpServerFormWrapper, { headers: existing });
await expect.element(bearerInput(screen)).toBeVisible();
it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
-
const screen = await render(McpServerFormWrapper, { headers: existing });
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
const headerKeyInput = screen.getByPlaceholder('Header name');
+
await expect.element(headerKeyInput).toBeVisible();
});
it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
-
const screen = await render(McpServerFormWrapper, { headers: existing });
await screen.getByRole('switch', { name: /authorization/i }).click();
await bearerInput(screen).fill('new');
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
+
await expect
.element(capturedHeaders(screen))
.toHaveAttribute('data-captured-headers', expected);
it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
const screen = await render(McpServerFormWrapper, { headers: existing });
-
const headerKeyInput = screen.getByPlaceholder('Header name');
+
await expect.element(headerKeyInput).not.toBeInTheDocument();
});
});
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
import TestWrapper from './components/TestWrapper.svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
describe('/+page.svelte', () => {
it('should render page without throwing', async () => {
// scrollIntoView on the initial mount, before the popover was positioned,
// so the browser scrolled every scrollable ancestor to reveal the row.
-import { describe, it, expect } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import { tick } from 'svelte';
import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
describe('ChatFormPickerList mount scroll', () => {
it('does not scroll documentElement when the picker mounts', async () => {
const screen = render(PickerListScrollHarness);
+
await tick();
document.documentElement.scrollTop = document.documentElement.scrollHeight;
await tick();
const before = document.documentElement.scrollTop;
+
expect(before).toBeGreaterThan(0);
screen.component.openPicker();
await tick();
const after = document.documentElement.scrollTop;
+
expect(after).toBe(before);
});
});
-import { beforeEach, describe, expect, it } from 'vitest';
-import { SandboxService } from '$lib/services/sandbox.service';
import { SANDBOX_TOOL_NAME } from '$lib/constants';
+import { SandboxService } from '$lib/services/sandbox.service';
+import { beforeEach, describe, expect, it } from 'vitest';
const run = (code: string, timeoutMs?: number) =>
SandboxService.executeTool(SANDBOX_TOOL_NAME, {
describe('sandbox service', () => {
beforeEach(async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.config = {
...settingsStore.config,
symbolicMathEnabled: true
it('executes plain JavaScript', async () => {
const reply = await run('return 1 + 1;');
+
expect(reply.isError).toBe(false);
expect(reply.content).toContain('=> 2');
});
it('exposes nerdamer for symbolic computation', async () => {
const reply = await run("return nerdamer.diff('sin(x)/x', 'x').toString();");
+
expect(reply.isError).toBe(false);
expect(reply.content).toContain('cos(x)');
});
it('computes exact rational arithmetic', async () => {
const reply = await run("return nerdamer('1/3 + 1/6').toString();");
+
expect(reply.isError).toBe(false);
expect(reply.content).toContain('=> 1/2');
});
const reply = await run(
"return nerdamer('expand((1+x*y)^3 - (1 + 3*x*y + 3*x^2*y^2 + x^3*y^3))').toString();"
);
+
expect(reply.isError).toBe(false);
expect(reply.content).toContain('=> 0');
});
const reply = await run(
"try { await fetch('https://example.com/'); return 'leaked'; } catch { return 'blocked'; }"
);
+
expect(reply.isError).toBe(false);
expect(reply.content).toContain('=> blocked');
});
it('enforces the timeout on runaway code', async () => {
const reply = await run('while (true) {}', 500);
+
expect(reply.isError).toBe(true);
expect(reply.content).toContain('timed out');
});
// winning when the legacy keys disagree. Legacy keys are removed from the
// persisted config so they do not stay orphaned in localStorage.
-import { beforeEach, describe, expect, it } from 'vitest';
-import { settingsStore, config } from '$lib/stores/settings.svelte';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
function seedConfig(stored: Record<string, unknown>) {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
});
it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
- seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
+ seedConfig({ renderThinkingAsMarkdown: false, renderUserContentAsMarkdown: true });
expect(config().renderContentAsRawText).toBe(true);
});
expect(config().renderContentAsRawText).toBe(false);
const stored = persisted();
+
expect(stored.renderUserContentAsMarkdown).toBeUndefined();
expect(stored.renderThinkingAsMarkdown).toBeUndefined();
expect(stored.renderUserContentAsRawText).toBeUndefined();
-import { beforeEach, describe, expect, it } from 'vitest';
-import { settingsStore, config } from '$lib/stores/settings.svelte';
-import { serverStore } from '$lib/stores/server.svelte';
-import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-registry';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { ParameterSyncService } from '$lib/services/parameter-sync.service';
+import { serverStore } from '$lib/stores/server.svelte';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
import type { SettingsConfigType } from '$lib/types';
+import { beforeEach, describe, expect, it } from 'vitest';
type Primitive = string | number | boolean;
function divergent(key: string, base: Primitive): Primitive {
if (typeof base === 'boolean') return !base;
+
if (typeof base === 'number') return base + 7;
+
return `user-${key}`;
}
function baselineFor(key: string, base: Primitive): Primitive {
if (typeof base === 'boolean') return !base;
+
if (typeof base === 'number') return base + 42;
+
return `admin-${key}`;
}
const setUser = (key: string, value: Primitive) =>
settingsStore.updateConfig(key as keyof SettingsConfigType, value as never);
-
const current = (key: string) => (config() as Record<string, unknown>)[key];
describe('registry-wide invariants', () => {
it('I1: no load ever modifies a stored user value, for any key of any type', () => {
settingsStore.initialize();
const userValues: Record<string, Primitive> = {};
+
for (const key of KEYS) {
userValues[key] = divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
setUser(key, userValues[key]);
// simulated F5 + adverse admin baseline on every key, synced twice
settingsStore.initialize();
const adverse: Record<string, Primitive> = {};
+
for (const key of KEYS)
adverse[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
mockProps(adverse);
it('first visit: the baseline applies for every key, false and 0 included', () => {
settingsStore.initialize();
const baseline: Record<string, Primitive> = {};
+
for (const key of KEYS)
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
mockProps(baseline);
for (const key of KEYS) {
if (ParameterSyncService.canSyncParameter(key)) continue;
+
expect(current(key), key).toBe(baseline[key]);
}
});
for (const key of KEYS) setUser(key, divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive));
const baseline: Record<string, Primitive> = {};
+
KEYS.filter((_, i) => i % 2 === 0).forEach((key) => {
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
});
-import { beforeEach, describe, expect, it } from 'vitest';
-import { settingsStore, config } from '$lib/stores/settings.svelte';
-import { serverStore } from '$lib/stores/server.svelte';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+import { serverStore } from '$lib/stores/server.svelte';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
function mockProps(uiSettings: Record<string, string | number | boolean>) {
Object.defineProperty(serverStore, 'props', {
it('applies the admin defaults once for a new user', () => {
settingsStore.initialize();
- mockProps({ theme: 'dark', apiKey: '' });
+ mockProps({ apiKey: '', theme: 'dark' });
settingsStore.syncWithServerDefaults();
// simulated F5: config now exists in localStorage
settingsStore.initialize();
- mockProps({ theme: 'dark', apiKey: '' });
+ mockProps({ apiKey: '', theme: 'dark' });
settingsStore.syncWithServerDefaults();
settingsStore.syncWithServerDefaults();
expect(config().theme).toBe('light');
expect(config().apiKey).toBe('sk-user-key');
const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+
expect(stored.apiKey).toBe('sk-user-key');
});
settingsStore.initialize();
settingsStore.updateConfig('theme', 'light');
settingsStore.updateConfig('apiKey', 'sk-user-key');
- mockProps({ theme: 'dark', apiKey: '' });
+ mockProps({ apiKey: '', theme: 'dark' });
settingsStore.forceSyncWithServerDefaults();
// made per-token cost scale with conversation length (1.26ms at 1 prior message
// -> 3.07ms at 40). Mutating in place keeps it flat.
-import { describe, it, expect } from 'vitest';
+import { MessageRole } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { DatabaseMessage } from '$lib/types';
-import { MessageRole } from '$lib/enums';
+import { describe, expect, it } from 'vitest';
function makeMessage(id: string): DatabaseMessage {
return {
- id,
- convId: 'c1',
- type: 'text',
- timestamp: 0,
- role: MessageRole.ASSISTANT,
+ children: [],
content: '',
+ convId: 'c1',
+ id,
parent: null,
- children: []
+ role: MessageRole.ASSISTANT,
+ timestamp: 0,
+ type: 'text'
} as DatabaseMessage;
}
setTimeout(() => reject(new Error('Service worker registration failed: timeout')), 15000)
)
]);
+
// @ts-expect-error registration is of type unknown
return registration.active?.scriptURL;
});
const swActive = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
+
return reg.active?.scriptURL ?? null;
});
await offlinePage.goto('/');
const bodyText = await offlinePage.locator('body').textContent();
+
expect(bodyText).toBeTruthy();
await context.close();
test('version.json is accessible and contains version', async ({ page }) => {
const versionResponse = await page.request.get('/_app/version.json');
+
expect(versionResponse.ok()).toBeTruthy();
const versionData = await versionResponse.json();
+
expect(versionData).toHaveProperty('version');
expect(typeof versionData.version).toBe('string');
expect(versionData.version.length).toBeGreaterThan(0);
test('manifest.webmanifest is accessible and valid', async ({ page }) => {
const response = await page.request.get('/manifest.webmanifest');
+
expect(response.ok()).toBeTruthy();
const manifest = await response.json();
+
expect(manifest).toHaveProperty('name', 'llama-ui');
expect(manifest).toHaveProperty('short_name', 'llama-ui');
expect(manifest).toHaveProperty('start_url', './');
test('index.html contains content-hashed bundle references', async ({ page }) => {
const response = await page.request.get('/');
+
expect(response.ok()).toBeTruthy();
const html = await response.text();
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
const { Story } = defineMeta({
- title: 'Components/ChatScreen/ChatMessage',
component: ChatMessage,
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/ChatScreen/ChatMessage'
});
// Mock messages for different scenarios
const userMessage: DatabaseMessage = {
- id: '1',
- convId: 'conv-1',
- type: 'message',
- timestamp: Date.now() - 1000 * 60 * 5,
- role: 'user',
+ children: [],
content: 'What is the meaning of life, the universe, and everything?',
+ convId: 'conv-1',
+ id: '1',
parent: '',
+ role: 'user',
thinking: '',
- children: []
+ timestamp: Date.now() - 1000 * 60 * 5,
+ type: 'message'
};
const assistantMessage: DatabaseMessage = {
- id: '2',
- convId: 'conv-1',
- type: 'message',
- timestamp: Date.now() - 1000 * 60 * 3,
- role: 'assistant',
+ children: [],
content:
'The answer to the ultimate question of life, the universe, and everything is **42**.\n\nThis comes from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy," where a supercomputer named Deep Thought calculated this answer over 7.5 million years. However, the question itself was never properly formulated, which is why the answer seems meaningless without context.',
+ convId: 'conv-1',
+ id: '2',
parent: '1',
+ role: 'assistant',
thinking: '',
- children: []
+ timestamp: Date.now() - 1000 * 60 * 3,
+ type: 'message'
};
const assistantWithReasoning: DatabaseMessage = {
- id: '3',
- convId: 'conv-1',
- type: 'message',
- timestamp: Date.now() - 1000 * 60 * 2,
- role: 'assistant',
+ children: [],
content: "Here's the concise answer, now that I've thought it through carefully for you.",
+ convId: 'conv-1',
+ id: '3',
parent: '1',
+ role: 'assistant',
thinking:
"Let's consider the user's question step by step:\\n\\n1. Identify the core problem\\n2. Evaluate relevant information\\n3. Formulate a clear answer\\n\\nFollowing this process ensures the final response stays focused and accurate.",
- children: []
+ timestamp: Date.now() - 1000 * 60 * 2,
+ type: 'message'
};
const rawOutputMessage: DatabaseMessage = {
- id: '6',
- convId: 'conv-1',
- type: 'message',
- timestamp: Date.now() - 1000 * 60,
- role: 'assistant',
+ children: [],
content:
'<|channel|>analysis<|message|>User greeted me. Initiating overcomplicated analysis: Is this a trap? No, just a normal hello. Respond calmly, act like a helpful assistant, and do not start explaining quantum physics again. Confidence 0.73. Engaging socially acceptable greeting protocol...<|end|>Hello there! How can I help you today?',
+ convId: 'conv-1',
+ id: '6',
parent: '1',
+ role: 'assistant',
thinking: '',
- children: []
+ timestamp: Date.now() - 1000 * 60,
+ type: 'message'
};
let processingMessage = $state({
- id: '4',
- convId: 'conv-1',
- type: 'message',
- timestamp: 0, // No timestamp = processing
- role: 'assistant',
+ children: [],
content: '',
+ convId: 'conv-1',
+ id: '4',
parent: '1',
+ role: 'assistant',
thinking: '',
- children: []
+ timestamp: 0, // No timestamp = processing
+ type: 'message'
});
let streamingMessage = $state({
- id: '5',
- convId: 'conv-1',
- type: 'message',
- timestamp: 0, // No timestamp = streaming
- role: 'assistant',
+ children: [],
content: '',
+ convId: 'conv-1',
+ id: '5',
parent: '1',
+ role: 'assistant',
thinking: '',
- children: []
+ timestamp: 0, // No timestamp = streaming
+ type: 'message'
});
</script>
}}
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', false);
}}
/>
}}
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', false);
}}
/>
}}
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', false);
}}
/>
}}
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', true);
}}
/>
asChild
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', false);
// Phase 1: Stream reasoning content in chunks
let reasoningText =
'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.';
-
let reasoningChunk = 'I';
let i = 0;
+
while (i < reasoningText.length) {
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
const chunk = reasoningText.slice(i, i + chunkSize);
+
reasoningChunk += chunk;
// Update the reactive state directly
"Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively.";
let contentChunk = '';
+
i = 0;
while (i < regularText.length) {
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
const chunk = regularText.slice(i, i + chunkSize);
+
contentChunk += chunk;
// Update the reactive state directly
}}
play={async () => {
const { settingsStore } = await import('$lib/stores/settings.svelte');
+
settingsStore.updateConfig('showRawOutputSwitch', false);
// Import the chat store to simulate loading state
const { chatStore } = await import('$lib/stores/chat.svelte');
<script module lang="ts">
+ import jpgAsset from './fixtures/assets/1.jpg?url';
+ import pdfAsset from './fixtures/assets/example.pdf?raw';
+ import svgAsset from './fixtures/assets/hf-logo.svg?url';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
import { expect } from 'storybook/test';
- import jpgAsset from './fixtures/assets/1.jpg?url';
- import svgAsset from './fixtures/assets/hf-logo.svg?url';
- import pdfAsset from './fixtures/assets/example.pdf?raw';
const { Story } = defineMeta({
- title: 'Components/ChatScreen/ChatScreenForm',
component: ChatScreenForm,
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/ChatScreen/ChatScreenForm'
});
let fileAttachments = $state([
{
+ file: new File([''], '1.jpg', { type: 'image/jpeg' }),
id: '1',
name: '1.jpg',
- type: 'image/jpeg',
- size: 44891,
preview: jpgAsset,
- file: new File([''], '1.jpg', { type: 'image/jpeg' })
+ size: 44891,
+ type: 'image/jpeg'
},
{
+ file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' }),
id: '2',
name: 'hf-logo.svg',
- type: 'image/svg+xml',
- size: 1234,
preview: svgAsset,
- file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
+ size: 1234,
+ type: 'image/svg+xml'
},
{
+ file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' }),
id: '3',
name: 'example.pdf',
- type: 'application/pdf',
size: 351048,
- file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
+ type: 'application/pdf'
}
]);
</script>
await expect(textarea).toHaveValue(text);
const fileInput = document.querySelector('input[type="file"]');
+
await expect(fileInput).not.toHaveAttribute('accept');
}}
/>
<script module lang="ts">
- import { defineMeta } from '@storybook/addon-svelte-csf';
- import { expect } from 'storybook/test';
- import { MarkdownContent } from '$lib/components/app';
import { AI_TUTORIAL_MD } from './fixtures/ai-tutorial.js';
import { API_DOCS_MD } from './fixtures/api-docs.js';
import { BLOG_POST_MD } from './fixtures/blog-post.js';
import { DATA_ANALYSIS_MD } from './fixtures/data-analysis.js';
- import { README_MD } from './fixtures/readme.js';
- import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
import { EMPTY_MD } from './fixtures/empty.js';
+ import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
+ import { README_MD } from './fixtures/readme.js';
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import { MarkdownContent } from '$lib/components/app';
+ import { expect } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/MarkdownContent',
component: MarkdownContent,
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/MarkdownContent'
});
</script>
-<Story name="Empty" args={{ content: EMPTY_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }} />
+<Story name="Empty" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: EMPTY_MD }} />
<Story
name="AI Tutorial"
- args={{ content: AI_TUTORIAL_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: AI_TUTORIAL_MD }}
/>
<Story
name="API Documentation"
- args={{ content: API_DOCS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: API_DOCS_MD }}
/>
<Story
name="Technical Blog"
- args={{ content: BLOG_POST_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: BLOG_POST_MD }}
/>
<Story
name="Data Analysis"
- args={{ content: DATA_ANALYSIS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: DATA_ANALYSIS_MD }}
/>
<Story
name="README file"
- args={{ content: README_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: README_MD }}
/>
<Story
name="Math Formulas"
- args={{ content: MATH_FORMULAS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: MATH_FORMULAS_MD }}
/>
<Story
name="URL Links"
args={{
+ class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
content: `# URL Links Test
Here are some example URLs that should open in new tabs:
You can also test inline links like https://example.com or https://docs.python.org.
-All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
- class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
+All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`
}}
play={async (context) => {
const { canvasElement } = context;
+
// Wait for component to render
await new Promise((resolve) => setTimeout(resolve, 100));
const hugginFaceLink = linkList.find(
(link) => link.getAttribute('href') === 'https://huggingface.co'
);
+
expect(hugginFaceLink).toBeTruthy();
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
const githubLink = linkList.find(
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
);
+
expect(githubLink).toBeTruthy();
expect(githubLink?.textContent).toBe('GitHub Repository');
const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
+
expect(openaiLink).toBeTruthy();
expect(openaiLink?.textContent).toBe('OpenAI Website');
const googleLink = linkList.find(
(link) => link.getAttribute('href') === 'https://www.google.com'
);
+
expect(googleLink).toBeTruthy();
expect(googleLink?.textContent).toBe('Google Search');
const exampleLink = linkList.find(
(link) => link.getAttribute('href') === 'https://example.com'
);
+
expect(exampleLink).toBeTruthy();
const pythonDocsLink = linkList.find(
(link) => link.getAttribute('href') === 'https://docs.python.org'
);
+
expect(pythonDocsLink).toBeTruthy();
console.log(`✅ URL Links test passed - Found ${links.length} links with proper attributes`);
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
- import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
+ import { modelsStore } from '$lib/stores/models.svelte';
const { Story } = defineMeta({
- title: 'Components/ModelsSelector',
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/ModelsSelector'
});
const mockModel = (id: string, name: string, orgName?: string, tags?: string[]): ModelOption => ({
+ capabilities: [],
id,
- name,
model: orgName ? `${orgName}/${name}` : name,
- capabilities: [],
+ name,
parsedId: {
- raw: orgName ? `${orgName}/${name}` : name,
- orgName: orgName ?? null,
+ activatedParams: null,
modelName: name,
+ orgName: orgName ?? null,
params: null,
- activatedParams: null,
quantization: null,
+ raw: orgName ? `${orgName}/${name}` : name,
tags: tags ?? []
},
tags
});
const mockRouterEntry = (modelName: string, status: ServerModelStatus): ApiModelDataEntry => ({
+ created: Date.now(),
id: modelName,
+ in_cache: true,
object: 'model',
owned_by: 'llamacpp',
- created: Date.now(),
- in_cache: true,
path: `/models/${modelName}`,
status: { value: status }
});
mockModelsStore();
const loadedModels: ModelItem[] = [
- { option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta'), flatIndex: 0 },
- { option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai'), flatIndex: 1 }
+ { flatIndex: 0, option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta') },
+ { flatIndex: 1, option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai') }
];
const favoriteModels: ModelItem[] = [
- { option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen'), flatIndex: 2 },
- { option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta'), flatIndex: 3 }
+ { flatIndex: 2, option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen') },
+ { flatIndex: 3, option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta') }
];
const availableModels: ModelItem[] = [
{
- option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding']),
- flatIndex: 4
+ flatIndex: 4,
+ option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding'])
},
- { option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google'), flatIndex: 5 },
- { option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft'), flatIndex: 6 },
- { option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']), flatIndex: 7 },
- { option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel'), flatIndex: 8 }
+ { flatIndex: 5, option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google') },
+ { flatIndex: 6, option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft') },
+ { flatIndex: 7, option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']) },
+ { flatIndex: 8, option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel') }
];
const groupedOptions: GroupedModelOptions = {
- loaded: loadedModels,
- favorites: favoriteModels,
available: [
{
- orgName: 'deepseek',
- items: [availableModels[0]]
+ items: [availableModels[0]],
+ orgName: 'deepseek'
},
{
- orgName: 'google',
- items: [availableModels[1]]
+ items: [availableModels[1]],
+ orgName: 'google'
},
{
- orgName: 'microsoft',
- items: [availableModels[2]]
+ items: [availableModels[2]],
+ orgName: 'microsoft'
},
{
- orgName: 'codellama',
- items: [availableModels[3]]
+ items: [availableModels[3]],
+ orgName: 'codellama'
},
{
- orgName: 'intel',
- items: [availableModels[4]]
+ items: [availableModels[4]],
+ orgName: 'intel'
}
- ]
+ ],
+ favorites: favoriteModels,
+ loaded: loadedModels
};
function handleSelect(modelId: string) {
const opt = [...loadedModels, ...favoriteModels, ...availableModels].find(
(m) => m.option.id === modelId
);
+
if (opt) {
selectedModel = opt.option.model;
activeId = modelId;
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
<ModelsSelectorList
groups={{
- loaded: [loadedModels[0]],
+ available: [],
favorites: [],
- available: []
+ loaded: [loadedModels[0]]
}}
currentModel={null}
activeId={null}
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
<ModelsSelectorList
groups={{
- loaded: [],
+ available: [],
favorites: favoriteModels,
- available: []
+ loaded: []
}}
currentModel={null}
activeId={null}
import { expect } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/PwaRefreshAlert',
component: PwaRefreshAlert,
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/PwaRefreshAlert'
});
</script>
args={{ needRefresh: true, updateServiceWorker: () => console.log('reload') }}
play={async ({ canvas }) => {
const title = canvas.getByText('Update available');
+
await expect(title).toBeInTheDocument();
const description = canvas.getByText(/A new version is available/);
+
await expect(description).toBeInTheDocument();
const button = canvas.getByRole('button', { name: 'Reload' });
+
await expect(button).toBeInTheDocument();
}}
/>
args={{ needRefresh: false, updateServiceWorker: () => console.log('reload') }}
play={async ({ canvas }) => {
const title = canvas.queryByText('Update available');
+
await expect(title).not.toBeInTheDocument();
}}
/>
}}
play={async ({ canvas, userEvent }) => {
const button = canvas.getByRole('button', { name: 'Reload' });
+
await expect(button).toBeInTheDocument();
await userEvent.click(button);
const title = canvas.queryByText('Update available');
+
await expect(title).not.toBeInTheDocument();
const reloadBtn = canvas.queryByRole('button', { name: 'Reload' });
+
await expect(reloadBtn).not.toBeInTheDocument();
}}
/>
import { screen } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/SidebarNavigation',
component: SidebarNavigation,
parameters: {
layout: 'centered'
- }
+ },
+ title: 'Components/SidebarNavigation'
});
</script>
// Mock conversations for the sidebar
const mockConversations: DatabaseConversation[] = [
{
+ currNode: 'msg-1',
id: 'conv-1',
- name: 'Getting Started with AI',
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
- currNode: 'msg-1'
+ name: 'Getting Started with AI'
},
{
+ currNode: 'msg-2',
id: 'conv-2',
- name: 'Python Programming Help',
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
- currNode: 'msg-2'
+ name: 'Python Programming Help'
},
{
+ currNode: 'msg-3',
id: 'conv-3',
- name: 'Creative Writing Ideas',
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
- currNode: 'msg-3'
+ name: 'Creative Writing Ideas'
},
{
+ currNode: 'msg-4',
id: 'conv-4',
- name: 'This is a very long conversation title that should be truncated properly when displayed',
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
- currNode: 'msg-4'
+ name: 'This is a very long conversation title that should be truncated properly when displayed'
},
{
+ currNode: 'msg-5',
id: 'conv-5',
- name: 'Math Problem Solving',
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
- currNode: 'msg-5'
+ name: 'Math Problem Solving'
}
];
</script>
// Expand sidebar first, then click Search in the expanded button list
const logoTrigger = screen.getByRole('button', { name: /expand navigation/i });
+
await userEvent.click(logoTrigger);
const searchTrigger = screen.getByText('Search');
+
userEvent.click(searchTrigger);
}}
>
play={async () => {
// Mock empty conversations store
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.conversations = [];
}}
>
<script module lang="ts">
- import { defineMeta } from '@storybook/addon-svelte-csf';
import { Copy } from '@lucide/svelte';
+ import { defineMeta } from '@storybook/addon-svelte-csf';
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
import { expect } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/ActionIcon/Accessibility',
component: ActionIcon,
parameters: {
layout: 'centered'
},
- tags: ['!dev']
+ tags: ['!dev'],
+ title: 'Components/ActionIcon/Accessibility'
});
</script>
import { expect } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/ChatMessageStatistics/Accessibility',
component: ChatMessageStatistics,
parameters: {
layout: 'centered'
},
- tags: ['!dev']
+ tags: ['!dev'],
+ title: 'Components/ChatMessageStatistics/Accessibility'
});
</script>
<Story
name="ViewButtonsSingleTabStop"
args={{
- promptTokens: 100,
- promptMs: 500,
- predictedTokens: 200,
- predictedMs: 1000,
agenticTimings: {
- turns: 1,
+ llm: { predicted_ms: 1000, predicted_n: 200, prompt_ms: 500, prompt_n: 100 },
toolCallsCount: 1,
toolsMs: 500,
- llm: { predicted_n: 200, predicted_ms: 1000, prompt_n: 100, prompt_ms: 500 }
+ turns: 1
},
hideSummary: false,
- isLive: false
+ isLive: false,
+ predictedMs: 1000,
+ predictedTokens: 200,
+ promptMs: 500,
+ promptTokens: 100
}}
play={async ({ canvas, userEvent }) => {
const reading = await canvas.findByRole('button', { name: 'Reading' });
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
- import { expect, screen, waitFor } from 'storybook/test';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
+ import { expect, screen, waitFor } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/ChatScreen/ChatScreenForm/Accessibility',
component: ChatScreenForm,
parameters: {
layout: 'centered'
},
- tags: ['!dev']
+ tags: ['!dev'],
+ title: 'Components/ChatScreen/ChatScreenForm/Accessibility'
});
</script>
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
play={async ({ canvas, userEvent }) => {
const textarea = await canvas.findByRole('textbox');
+
await userEvent.clear(textarea);
await userEvent.type(textarea, 'What is the meaning of life?');
import { expect, waitFor } from 'storybook/test';
const { Story } = defineMeta({
- title: 'Components/HorizontalScrollCarousel/Accessibility',
component: HorizontalScrollCarousel,
parameters: {
layout: 'centered'
},
- tags: ['!dev']
+ tags: ['!dev'],
+ title: 'Components/HorizontalScrollCarousel/Accessibility'
});
</script>
import { expect } from 'storybook/test';
const mockForkedConversation: DatabaseConversation = {
+ currNode: 'msg-2',
+ forkedFromConversationId: 'conv-1',
id: 'conv-2',
- name: 'Forked Conversation',
lastModified: Date.now(),
- currNode: 'msg-2',
- forkedFromConversationId: 'conv-1'
+ name: 'Forked Conversation'
};
const { Story } = defineMeta({
- title: 'Components/SidebarNavigationConversationItem/Accessibility',
component: SidebarNavigationConversationItem,
parameters: {
layout: 'centered'
},
- tags: ['!dev']
+ tags: ['!dev'],
+ title: 'Components/SidebarNavigationConversationItem/Accessibility'
});
</script>
-import { serverStore } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
/**
* Mock server properties for Storybook testing
export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
// Reset any pointer-events from previous tests (dropdown cleanup)
const body = document.querySelector('body');
+
if (body) body.style.pointerEvents = '';
// Directly set the props for testing purposes
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
- model_path: props.model_path || 'test-model',
modalities: {
- vision: props.modalities?.vision ?? false,
audio: props.modalities?.audio ?? false,
- video: props.modalities?.video ?? false
+ video: props.modalities?.video ?? false,
+ vision: props.modalities?.vision ?? false
},
+ model_path: props.model_path || 'test-model',
...props
} as ApiLlamaCppServerProps;
(modelsStore as any).models = [
{
id: 'test-model',
- name: 'Test Model',
- model: 'test-model'
+ model: 'test-model',
+ name: 'Test Model'
}
];
*/
export function resetServerStore(): void {
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
- model_path: '',
modalities: {
- vision: false,
audio: false,
- video: false
- }
+ video: false,
+ vision: false
+ },
+ model_path: ''
} as ApiLlamaCppServerProps;
(serverStore as unknown as { error: string }).error = '';
(serverStore as unknown as { loading: boolean }).loading = false;
* Common mock configurations for Storybook stories
*/
export const mockConfigs = {
- visionOnly: {
- modalities: { vision: true, audio: false }
- },
audioOnly: {
- modalities: { vision: false, audio: true }
+ modalities: { audio: true, vision: false }
},
bothModalities: {
- modalities: { vision: true, audio: true }
+ modalities: { audio: true, vision: true }
},
noModalities: {
- modalities: { vision: false, audio: false, video: false }
+ modalities: { audio: false, video: false, vision: false }
+ },
+ visionOnly: {
+ modalities: { audio: false, vision: true }
}
} as const;
-import { describe, expect, it } from 'vitest';
import { isAbortError } from '$lib/utils/abort';
+import { describe, expect, it } from 'vitest';
describe('isAbortError', () => {
it('returns false for null, undefined and non-error values', () => {
it('returns true for DOMException with AbortError name', () => {
const err = new DOMException('Operation was aborted', 'AbortError');
+
expect(isAbortError(err)).toBe(true);
});
it('returns true for plain Error with AbortError name', () => {
const err = new Error('aborted');
+
err.name = 'AbortError';
expect(isAbortError(err)).toBe(true);
});
//
// Run: npx vitest bench --project=unit tests/unit/agentic-hotpath.bench.ts
-import { bench, describe } from 'vitest';
-
+import { classifyToolResult, parseToolResultWithImages } from '$lib/utils/agentic';
+import { detectIncompleteCodeBlock, highlightCode } from '$lib/utils/code';
+import { computeLineDiff } from '$lib/utils/compute-line-diff';
+import { preprocessLaTeX } from '$lib/utils/latex-protection';
+import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
+import { extractSearchQuery, extractSearchResults } from '$lib/utils/search-results';
+import { all as lowlightAll } from 'lowlight';
+import rehypeHighlight from 'rehype-highlight';
+import rehypeKatex from 'rehype-katex';
+import rehypeStringify from 'rehype-stringify';
import { remark } from 'remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype';
-import rehypeKatex from 'rehype-katex';
-import rehypeHighlight from 'rehype-highlight';
-import rehypeStringify from 'rehype-stringify';
-import { all as lowlightAll } from 'lowlight';
-
-import { computeLineDiff } from '$lib/utils/compute-line-diff';
-import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
-import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
-import { highlightCode, detectIncompleteCodeBlock } from '$lib/utils/code';
-import { classifyToolResult, parseToolResultWithImages } from '$lib/utils/agentic';
-import { preprocessLaTeX } from '$lib/utils/latex-protection';
+import { bench, describe } from 'vitest';
// --- fixtures -------------------------------------------------------------
function lines(n: number, seed: string): string {
const out: string[] = [];
+
for (let i = 0; i < n; i++) out.push(`${seed} line ${i} const value_${i} = compute(${i});`);
+
return out.join('\n');
}
const SHELL_OUTPUT_1KB = lines(12, 'out');
const SHELL_OUTPUT_200KB = lines(2600, 'out');
const SHELL_OUTPUT_2MB = lines(26000, 'out');
-
// Realistic exec_shell_command result: the exit-code marker is the final line,
// which is what the un-anchored EXIT_CODE regex has to scan the whole blob for.
const SHELL_2MB_WITH_EXIT = `${SHELL_OUTPUT_2MB}\n[exit code: 0]`;
-
const EDIT_OLD_400 = lines(400, 'old');
const EDIT_NEW_400 = lines(400, 'new');
const EDIT_OLD_50 = lines(50, 'old');
const EDIT_NEW_50 = lines(50, 'new');
-
const WRITE_FILE_ARGS = JSON.stringify({
- path: '/src/lib/thing.ts',
- content: lines(1500, 'src')
+ content: lines(1500, 'src'),
+ path: '/src/lib/thing.ts'
});
-
const MARKDOWN_50KB = Array.from(
{ length: 400 },
(_, i) =>
`## Section ${i}\n\nSome **bold** prose with a [link](https://example.com) and \`inline\` code.\n\n- bullet one\n- bullet two\n\n\`\`\`ts\nconst x${i} = ${i};\n\`\`\`\n`
).join('\n');
-
const CODE_BLOCK_5KB = lines(60, 'code');
// --- A: computeLineDiff (O(m*n) LCS, allocates a full matrix) --------------
function buildProcessor() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let proc: any = remark().use(remarkGfm);
+
proc = proc.use(remarkMath).use(remarkBreaks).use(remarkRehype).use(rehypeKatex);
+
return proc.use(rehypeHighlight, { languages: lowlightAll }).use(rehypeStringify, {
allowDangerousHtml: true
});
{ length: Math.ceil((kb * 1024) / 64) },
(_, i) => `The quick brown fox jumps over the lazy dog. Sentence ${i}.`
).join(' ');
-
const MD_3KB = prose(3);
const MD_11KB = prose(11);
const MD_26KB = prose(26);
{ length: Math.ceil((kb * 1024) / 64) },
(_, i) => `The quick brown fox jumps over the lazy dog. Sentence ${i}.`
).join(' ');
-
const MD_3KB = prose(3);
const MD_11KB = prose(11);
const MD_26KB = prose(26);
-import { describe, it, expect } from 'vitest';
-import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
import { AgenticSectionType, MessageRole } from '$lib/enums';
-import type { DatabaseMessage } from '$lib/types/database';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
+import type { DatabaseMessage } from '$lib/types/database';
+import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
+import { describe, expect, it } from 'vitest';
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
- id: overrides.id ?? 'ast-1',
- convId: 'conv-1',
- type: 'text',
- timestamp: Date.now(),
- role: MessageRole.ASSISTANT,
+ children: [],
content: overrides.content ?? '',
+ convId: 'conv-1',
+ id: overrides.id ?? 'ast-1',
parent: null,
- children: [],
+ role: MessageRole.ASSISTANT,
+ timestamp: Date.now(),
+ type: 'text',
...overrides
} as DatabaseMessage;
}
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
- id: overrides.id ?? 'tool-1',
- convId: 'conv-1',
- type: 'text',
- timestamp: Date.now(),
- role: MessageRole.TOOL,
+ children: [],
content: overrides.content ?? 'tool result',
+ convId: 'conv-1',
+ id: overrides.id ?? 'tool-1',
parent: null,
- children: [],
+ role: MessageRole.TOOL,
+ timestamp: Date.now(),
toolCallId: overrides.toolCallId ?? 'call_1',
+ type: 'text',
...overrides
} as DatabaseMessage;
}
it('returns empty array for assistant with no content', () => {
const msg = makeAssistant({ content: '' });
const sections = deriveAgenticSections(msg);
+
expect(sections).toEqual([]);
});
it('returns text section for simple assistant message', () => {
const msg = makeAssistant({ content: 'Hello world' });
const sections = deriveAgenticSections(msg);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[0].content).toBe('Hello world');
reasoningContent: 'Let me think...'
});
const sections = deriveAgenticSections(msg);
+
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
expect(sections[0].content).toBe('Let me think...');
content: 'Let me check.',
toolCalls: JSON.stringify([
{
+ function: { arguments: '{"q":"test"}', name: 'search' },
id: 'call_1',
- type: 'function',
- function: { name: 'search', arguments: '{"q":"test"}' }
+ type: 'function'
}
])
});
const toolResult = makeToolMsg({
- toolCallId: 'call_1',
- content: 'Found 3 results'
+ content: 'Found 3 results',
+ toolCallId: 'call_1'
});
const sections = deriveAgenticSections(msg, [toolResult]);
+
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
it('single turn: pending tool call without result', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'bash' }, id: 'call_1', type: 'function' }
])
});
const sections = deriveAgenticSections(msg, [], [], true);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
expect(sections[0].toolName).toBe('bash');
const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
const msg = makeAssistant({
toolCalls: JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'write_file', arguments: partialArgs } }
+ { function: { arguments: partialArgs, name: 'write_file' }, id: 'call_1', type: 'function' }
])
});
const sections = deriveAgenticSections(msg, [], [], true);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
it('multi-turn: two assistant turns grouped as one session', () => {
const assistant1 = makeAssistant({
- id: 'ast-1',
content: 'Turn 1 text',
+ id: 'ast-1',
toolCalls: JSON.stringify([
{
+ function: { arguments: '{"q":"foo"}', name: 'search' },
id: 'call_1',
- type: 'function',
- function: { name: 'search', arguments: '{"q":"foo"}' }
+ type: 'function'
}
])
});
- const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
+ const tool1 = makeToolMsg({ content: 'result 1', id: 'tool-1', toolCallId: 'call_1' });
const assistant2 = makeAssistant({
- id: 'ast-2',
- content: 'Final answer based on results.'
+ content: 'Final answer based on results.',
+ id: 'ast-2'
});
-
// toolMessages contains both tool result and continuation assistant
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
+
expect(sections).toHaveLength(3);
// Turn 1
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
it('multi-turn: three turns with tool calls', () => {
const assistant1 = makeAssistant({
- id: 'ast-1',
content: '',
+ id: 'ast-1',
toolCalls: JSON.stringify([
{
+ function: { arguments: '{}', name: 'list_files' },
id: 'call_1',
- type: 'function',
- function: { name: 'list_files', arguments: '{}' }
+ type: 'function'
}
])
});
- const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
+ const tool1 = makeToolMsg({ content: 'file1 file2', id: 'tool-1', toolCallId: 'call_1' });
const assistant2 = makeAssistant({
- id: 'ast-2',
content: 'Reading file1...',
+ id: 'ast-2',
toolCalls: JSON.stringify([
{
+ function: { arguments: '{"path":"file1"}', name: 'read_file' },
id: 'call_2',
- type: 'function',
- function: { name: 'read_file', arguments: '{"path":"file1"}' }
+ type: 'function'
}
])
});
const tool2 = makeToolMsg({
+ content: 'contents of file1',
id: 'tool-2',
- toolCallId: 'call_2',
- content: 'contents of file1'
+ toolCallId: 'call_2'
});
const assistant3 = makeAssistant({
- id: 'ast-3',
content: 'Here is the analysis.',
+ id: 'ast-3',
reasoningContent: 'The file contains...'
});
-
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
+
// Turn 1: tool_call (no text since content is empty)
// Turn 2: text + tool_call
// Turn 3: reasoning + text
reasoningContent: 'Let me think about this...'
});
const sections = deriveAgenticSections(msg, [], [], true);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING);
expect(sections[0].content).toBe('Let me think about this...');
reasoningContent: 'Let me think...'
});
const sections = deriveAgenticSections(msg, [], [], true);
+
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
reasoningContent: 'Let me think...'
});
const sections = deriveAgenticSections(msg, [], [], false);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
});
it('multi-turn: streaming tool calls on last turn', () => {
const assistant1 = makeAssistant({
toolCalls: JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'search' }, id: 'call_1', type: 'function' }
])
});
- const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
- const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
-
+ const tool1 = makeToolMsg({ content: 'result', toolCallId: 'call_1' });
+ const assistant2 = makeAssistant({ content: '', id: 'ast-2' });
const streamingToolCalls: ApiChatCompletionToolCall[] = [
- { id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
+ { function: { arguments: '{"pa', name: 'write_file' }, id: 'call_2', type: 'function' }
];
-
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
+
// Turn 1: tool_call
// Turn 2 (streaming): streaming tool call
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
describe('hasAgenticContent', () => {
it('returns false for plain assistant', () => {
const msg = makeAssistant({ content: 'Just text' });
+
expect(hasAgenticContent(msg)).toBe(false);
});
it('returns true when message has toolCalls', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
])
});
+
expect(hasAgenticContent(msg)).toBe(true);
});
it('returns true when toolMessages are provided', () => {
const msg = makeAssistant();
const tool = makeToolMsg();
+
expect(hasAgenticContent(msg, [tool])).toBe(true);
});
it('returns false for empty toolCalls JSON', () => {
const msg = makeAssistant({ toolCalls: '[]' });
+
expect(hasAgenticContent(msg)).toBe(false);
});
});
-import { describe, it, expect } from 'vitest';
import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
+import { describe, expect, it } from 'vitest';
/**
* Tests for legacy marker stripping (used in migration).
'<<<TOOL_ARGS_END>>>\n' +
'file1.txt\nfile2.txt\n' +
'<<<AGENTIC_TOOL_CALL_END>>>\n';
-
// Partial block: streaming was cut before END arrived.
const OPEN_BLOCK =
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
it('strips a complete tool call block, leaving surrounding text', () => {
const input = 'Before.' + COMPLETE_BLOCK + 'After.';
const result = stripLegacyContextMarkers(input);
+
expect(result).not.toContain('<<<');
expect(result).toContain('Before.');
expect(result).toContain('After.');
it('strips multiple complete tool call blocks', () => {
const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
const result = stripLegacyContextMarkers(input);
+
expect(result).not.toContain('<<<');
expect(result).toContain('A');
expect(result).toContain('B');
it('strips an open/partial tool call block (no END marker)', () => {
const input = 'Lead text.' + OPEN_BLOCK;
const result = stripLegacyContextMarkers(input);
+
expect(result).toBe('Lead text.');
expect(result).not.toContain('<<<');
});
it('does not alter content with no markers', () => {
const input = 'Just a normal assistant response.';
+
expect(stripLegacyContextMarkers(input)).toBe(input);
});
it('strips reasoning block independently', () => {
const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
+
expect(stripLegacyContextMarkers(input)).toBe('Answer.');
});
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
'Some text.' +
COMPLETE_BLOCK;
+
expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
expect(stripLegacyContextMarkers(input)).toContain('Some text.');
});
-import { describe, expect, it } from 'vitest';
-import { AgenticSectionType } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants';
-import { buildAssistantRawOutput, type AgenticSection } from '$lib/utils/agentic';
+import { AgenticSectionType } from '$lib/enums';
+import { type AgenticSection, buildAssistantRawOutput } from '$lib/utils/agentic';
+import { describe, expect, it } from 'vitest';
function makeSection(
overrides: Partial<AgenticSection> & { type: AgenticSectionType }
});
it('formats a reasoning section with a single newline between tags and content', () => {
- const sections = [makeSection({ type: AgenticSectionType.REASONING, content: 'thinking...' })];
+ const sections = [makeSection({ content: 'thinking...', type: AgenticSectionType.REASONING })];
+
expect(buildAssistantRawOutput(sections)).toBe(
`${REASONING_TAGS.START}\nthinking...${REASONING_TAGS.END}`
);
});
it('formats a text section as-is', () => {
- const sections = [makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' })];
+ const sections = [makeSection({ content: 'Hello', type: AgenticSectionType.TEXT })];
+
expect(buildAssistantRawOutput(sections)).toBe('Hello');
});
it('formats a tool call with JSON args and no result label', () => {
const sections = [
makeSection({
- type: AgenticSectionType.TOOL_CALL,
- toolName: 'read_file',
toolArgs: JSON.stringify({ path: '/tmp/file.txt' }),
- toolResult: 'file contents'
+ toolName: 'read_file',
+ toolResult: 'file contents',
+ type: AgenticSectionType.TOOL_CALL
})
];
+
expect(buildAssistantRawOutput(sections)).toBe(
[
'{',
it('joins multiple sections with double newlines', () => {
const sections = [
- makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' }),
- makeSection({ type: AgenticSectionType.TOOL_CALL, toolName: 'noop' })
+ makeSection({ content: 'Hello', type: AgenticSectionType.TEXT }),
+ makeSection({ toolName: 'noop', type: AgenticSectionType.TOOL_CALL })
];
+
expect(buildAssistantRawOutput(sections)).toBe('Hello\n\n{\n "name": "noop"\n}');
});
it('falls back to raw string args when JSON parsing fails', () => {
const sections = [
makeSection({
- type: AgenticSectionType.TOOL_CALL,
- toolName: 'broken',
toolArgs: '{not json',
- toolResult: 'result'
+ toolName: 'broken',
+ toolResult: 'result',
+ type: AgenticSectionType.TOOL_CALL
})
];
+
expect(buildAssistantRawOutput(sections)).toBe(
['{', ' "name": "broken",', ' "arguments": "{not json"', '}', '', '', 'result'].join('\n')
);
-import { describe, it, expect } from 'vitest';
import { classifyToolResult } from '$lib/utils/agentic';
+import { describe, expect, it } from 'vitest';
describe('classifyToolResult', () => {
describe('text', () => {
it('classifies a deeply nested JSON payload', () => {
const nested = JSON.stringify({ items: [{ id: 1, tags: ['a', 'b'] }] }, null, 2);
+
expect(classifyToolResult(nested)).toBe('json');
});
// `classifyToolResult` only inspects the top-level shape, not
// every nested line marker.
const jsonWithLink = '{"docs": "see [docs](https://example.com) for more"}';
+
expect(classifyToolResult(jsonWithLink)).toBe('json');
});
});
it('classifies a markdown table', () => {
const table = '| a | b |\n| - | - |\n| 1 | 2 |';
+
expect(classifyToolResult(table)).toBe('markdown');
});
it('classifies a markdown table with alignment markers', () => {
const table = '| left | center | right |\n| :--- | :---: | ---: |\n| a | b | c |';
+
expect(classifyToolResult(table)).toBe('markdown');
});
'| ----- | ----- |',
'| a | b |'
].join('\n');
+
expect(classifyToolResult(md)).toBe('markdown');
});
});
it('prefers JSON over markdown when both signals are present', () => {
// Starts with `[`, parses as JSON - markdown check is skipped.
const arr = '[1, 2, "# not-a-heading", "**not-bold**"]';
+
expect(classifyToolResult(arr)).toBe('json');
});
});
-import { describe, it, expect } from 'vitest';
import { AttachmentType } from '$lib/enums';
import {
formatMessageForClipboard,
- parseClipboardContent,
- hasClipboardAttachments
+ hasClipboardAttachments,
+ parseClipboardContent
} from '$lib/utils/clipboard';
+import { describe, expect, it } from 'vitest';
describe('formatMessageForClipboard', () => {
it('returns plain content when no extras', () => {
const result = formatMessageForClipboard('Hello world', undefined);
+
expect(result).toBe('Hello world');
});
it('returns plain content when extras is empty array', () => {
const result = formatMessageForClipboard('Hello world', []);
+
expect(result).toBe('Hello world');
});
it('handles empty string content', () => {
const result = formatMessageForClipboard('', undefined);
+
expect(result).toBe('');
});
it('returns plain content when extras has only non-text attachments', () => {
const extras = [
{
- type: AttachmentType.IMAGE as const,
+ base64Url: 'data:image/png;base64,...',
name: 'image.png',
- base64Url: 'data:image/png;base64,...'
+ type: AttachmentType.IMAGE as const
}
];
const result = formatMessageForClipboard('Hello world', extras);
+
expect(result).toBe('Hello world');
});
it('filters non-text attachments and keeps only text ones', () => {
const extras = [
{
- type: AttachmentType.IMAGE as const,
+ base64Url: 'data:image/png;base64,...',
name: 'image.png',
- base64Url: 'data:image/png;base64,...'
+ type: AttachmentType.IMAGE as const
},
{
- type: AttachmentType.TEXT as const,
+ content: 'Text content',
name: 'file.txt',
- content: 'Text content'
+ type: AttachmentType.TEXT as const
},
{
- type: AttachmentType.PDF as const,
- name: 'doc.pdf',
base64Data: 'data:application/pdf;base64,...',
content: 'PDF content',
- processedAsImages: false
+ name: 'doc.pdf',
+ processedAsImages: false,
+ type: AttachmentType.PDF as const
}
];
const result = formatMessageForClipboard('Hello', extras);
it('formats message with text attachments', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'File 1 content',
name: 'file1.txt',
- content: 'File 1 content'
+ type: AttachmentType.TEXT as const
},
{
- type: AttachmentType.TEXT as const,
+ content: 'File 2 content',
name: 'file2.txt',
- content: 'File 2 content'
+ type: AttachmentType.TEXT as const
}
];
const result = formatMessageForClipboard('Hello world', extras);
const content = 'Hello "world" with\nnewline';
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'Test content',
name: 'test.txt',
- content: 'Test content'
+ type: AttachmentType.TEXT as const
}
];
const result = formatMessageForClipboard(content, extras);
expect(result.startsWith('"')).toBe(true);
// The content should be properly escaped
const parsed = JSON.parse(result.split('\n')[0]);
+
expect(parsed).toBe(content);
});
it('converts legacy context type to TEXT type', () => {
const extras = [
{
- type: AttachmentType.LEGACY_CONTEXT as const,
+ content: 'Legacy content',
name: 'legacy.txt',
- content: 'Legacy content'
+ type: AttachmentType.LEGACY_CONTEXT as const
}
];
const result = formatMessageForClipboard('Hello', extras);
it('handles attachment content with special characters', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;',
name: 'code.js',
- content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard('Check this code', extras);
it('handles unicode characters in content and attachments', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: '日本語テスト 🎉 émojis',
name: 'unicode.txt',
- content: '日本語テスト 🎉 émojis'
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard('Привет мир 👋', extras);
it('formats as plain text when asPlainText is true', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'File 1 content',
name: 'file1.txt',
- content: 'File 1 content'
+ type: AttachmentType.TEXT as const
},
{
- type: AttachmentType.TEXT as const,
+ content: 'File 2 content',
name: 'file2.txt',
- content: 'File 2 content'
+ type: AttachmentType.TEXT as const
}
];
const result = formatMessageForClipboard('Hello world', extras, true);
it('returns plain content when asPlainText is true but no attachments', () => {
const result = formatMessageForClipboard('Hello world', [], true);
+
expect(result).toBe('Hello world');
});
it('plain text mode does not use JSON format', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'Test content',
name: 'test.txt',
- content: 'Test content'
+ type: AttachmentType.TEXT as const
}
];
const result = formatMessageForClipboard('Hello', extras, true);
it('returns original text when JSON array is malformed', () => {
const input = '"Hello"\n[invalid json';
-
const result = parseClipboardContent(input);
expect(result.message).toBe('"Hello"\n[invalid json');
{"type":"TEXT","name":"file1.txt","content":"File 1 content"},
{"type":"TEXT","name":"file2.txt","content":"File 2 content"}
]`;
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Hello world');
[
{"type":"TEXT","name":"file.txt","content":"test"}
]`;
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Hello "world" with quotes');
[
{"type":"TEXT","name":"file.txt","content":"test"}
]`;
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Hello\nworld');
it('returns message only when no array follows', () => {
const input = '"Just a quoted string"';
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Just a quoted string');
{"name":"missing-type.txt","content":"missing"},
{"type":"TEXT","content":"missing name"}
]`;
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Hello');
it('handles empty attachments array', () => {
const input = '"Hello"\n[]';
-
const result = parseClipboardContent(input);
expect(result.message).toBe('Hello');
const originalContent = 'Hello "world" with\nspecial characters';
const originalExtras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'Content with\nnewlines and "quotes"',
name: 'file1.txt',
- content: 'Content with\nnewlines and "quotes"'
+ type: AttachmentType.TEXT as const
},
{
- type: AttachmentType.TEXT as const,
+ content: 'Another file',
name: 'file2.txt',
- content: 'Another file'
+ type: AttachmentType.TEXT as const
}
];
-
const formatted = formatMessageForClipboard(originalContent, originalExtras);
const parsed = parseClipboardContent(formatted);
it('preserves empty message with attachments', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'Content only',
name: 'file.txt',
- content: 'Content only'
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard('', extras);
it('preserves attachment with empty content', () => {
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: '',
name: 'empty.txt',
- content: ''
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard('Message', extras);
const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: 'D:\\\\Data\\\\file',
name: 'path.txt',
- content: 'D:\\\\Data\\\\file'
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard(content, extras);
const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF';
const extras = [
{
- type: AttachmentType.TEXT as const,
+ content: '\t\t\n\n ',
name: 'whitespace.txt',
- content: '\t\t\n\n '
+ type: AttachmentType.TEXT as const
}
];
const formatted = formatMessageForClipboard(content, extras);
-import { describe, expect, it } from 'vitest';
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
+import { describe, expect, it } from 'vitest';
describe('trimCodePadding', () => {
it('removes a single leading newline', () => {
it('does not produce a leading newline in the highlighted html', () => {
const html = highlightCode('\nfunction multiply(a, b) {\n return a * b;\n}\n', 'javascript');
+
expect(html.startsWith('\n')).toBe(false);
expect(html.startsWith(' ')).toBe(false);
});
it('does not produce a trailing newline in the highlighted html', () => {
const html = highlightCode('\nfunction foo() {}\n', 'javascript');
+
expect(html.endsWith('\n')).toBe(false);
});
it('preserves internal blank lines in highlighted code', () => {
const html = highlightCode('\nfunction foo() {\n\n return 1;\n}\n', 'javascript');
+
expect(html).toContain('\n\n');
});
it('produces the same body for framed and unframed input', () => {
const trimmed = highlightCode('function foo() {}', 'javascript');
const framed = highlightCode('\nfunction foo() {}\n', 'javascript');
+
expect(framed).toBe(trimmed);
});
it('auto-detects an unknown language by default', () => {
const html = highlightCode('const answer = 42;', 'not-a-language');
+
expect(html).toContain('hljs-');
});
it('escapes instead of auto-detecting when autoDetect is false', () => {
const html = highlightCode('const answer = 42;', 'not-a-language', false);
+
expect(html).not.toContain('hljs-');
expect(html).toBe('const answer = 42;');
});
it('still highlights a known language when autoDetect is false', () => {
const html = highlightCode('const answer = 42;', 'javascript', false);
+
expect(html).toContain('hljs-');
});
it('escapes html metacharacters when falling back to plain text', () => {
const html = highlightCode('<script>a && b</script>', 'not-a-language', false);
+
expect(html).toBe('<script>a && b</script>');
});
});
describe('splitGluedClosingCodeFences', () => {
it('splits text glued to a closing fence onto its own line', () => {
const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)";
+
expect(splitGluedClosingCodeFences(input)).toBe(
"```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"
);
it('leaves a well-formed code block untouched', () => {
const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)";
+
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
it('keeps nested markdown fences inside a block intact', () => {
const input = '```md\n# Example\n```python\nprint(1)\n```\n```';
+
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
it('splits every glued closing fence when several blocks are present', () => {
const input = '```ts\na\n```first words\n\n```js\nb\n```second words';
+
expect(splitGluedClosingCodeFences(input)).toBe(
'```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words'
);
it('leaves a still-open fence untouched', () => {
const input = '```ts\nlet foo = 1;';
+
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
});
-import { describe, expect, it } from 'vitest';
import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('findCommandToken', () => {
it('returns null when the value does not start with a slash', () => {
});
it('parses a bare slash', () => {
- expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
+ expect(findCommandToken('/')).toEqual({ args: '', end: 1, name: '' });
});
it('parses a command name with no args', () => {
- expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
+ expect(findCommandToken('/prompt')).toEqual({ args: '', end: 7, name: 'prompt' });
});
it('parses a command name followed by a space', () => {
- expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
+ expect(findCommandToken('/prompt ')).toEqual({ args: '', end: 8, name: 'prompt' });
});
it('parses args after the command name', () => {
- expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
+ expect(findCommandToken('/prompt rev')).toEqual({ args: 'rev', end: 11, name: 'prompt' });
});
it('parses multi-word args', () => {
expect(findCommandToken('/prompt review code ')).toEqual({
- name: 'prompt',
args: ' review code ',
- end: 22
+ end: 22,
+ name: 'prompt'
});
});
it('treats the whole run as the name when there is no space', () => {
- expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
+ expect(findCommandToken('/promptx')).toEqual({ args: '', end: 8, name: 'promptx' });
});
});
it('captures the name and args', () => {
expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
- name: 'prompt',
- args: 'rev'
+ args: 'rev',
+ name: 'prompt'
});
});
});
-import { describe, expect, it } from 'vitest';
import { DiffLineKind } from '$lib/enums';
-import { computeLineDiff, renderUnifiedDiff, type DiffLine } from '$lib/utils';
+import { computeLineDiff, type DiffLine, renderUnifiedDiff } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('computeLineDiff', () => {
it('returns empty for two empty inputs', () => {
it('marks every line as removed for an empty new text', () => {
expect(computeLineDiff('a\nb\nc', '')).toEqual([
- { kind: 'remove', text: 'a', oldLine: 1 },
- { kind: 'remove', text: 'b', oldLine: 2 },
- { kind: 'remove', text: 'c', oldLine: 3 }
+ { kind: 'remove', oldLine: 1, text: 'a' },
+ { kind: 'remove', oldLine: 2, text: 'b' },
+ { kind: 'remove', oldLine: 3, text: 'c' }
]);
});
it('marks every line as added for an empty old text', () => {
expect(computeLineDiff('', 'a\nb')).toEqual([
- { kind: 'add', text: 'a', newLine: 1 },
- { kind: 'add', text: 'b', newLine: 2 }
+ { kind: 'add', newLine: 1, text: 'a' },
+ { kind: 'add', newLine: 2, text: 'b' }
]);
});
it('detects a single-line replace', () => {
expect(computeLineDiff('old', 'new')).toEqual([
- { kind: 'add', text: 'new', newLine: 1 },
- { kind: 'remove', text: 'old', oldLine: 1 }
+ { kind: 'add', newLine: 1, text: 'new' },
+ { kind: 'remove', oldLine: 1, text: 'old' }
]);
});
it('preserves interleaved context around additions', () => {
const oldText = ['a', 'b', 'c'].join('\n');
const newText = ['a', 'b', 'B', 'c'].join('\n');
+
expect(computeLineDiff(oldText, newText)).toEqual([
- { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
- { kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
- { kind: 'add', text: 'B', newLine: 3 },
- { kind: 'context', text: 'c', oldLine: 3, newLine: 4 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
+ { kind: 'context', newLine: 2, oldLine: 2, text: 'b' },
+ { kind: 'add', newLine: 3, text: 'B' },
+ { kind: 'context', newLine: 4, oldLine: 3, text: 'c' }
]);
});
// show context flanking the changed line at its natural position.
const oldText = ['a', 'b', 'c', 'd'].join('\n');
const newText = ['a', 'b', 'X', 'd'].join('\n');
+
expect(computeLineDiff(oldText, newText)).toEqual([
- { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
- { kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
- { kind: 'add', text: 'X', newLine: 3 },
- { kind: 'remove', text: 'c', oldLine: 3 },
- { kind: 'context', text: 'd', oldLine: 4, newLine: 4 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
+ { kind: 'context', newLine: 2, oldLine: 2, text: 'b' },
+ { kind: 'add', newLine: 3, text: 'X' },
+ { kind: 'remove', oldLine: 3, text: 'c' },
+ { kind: 'context', newLine: 4, oldLine: 4, text: 'd' }
]);
});
it('preserves interleaved context around removals', () => {
const oldText = ['a', 'b', 'c', 'd'].join('\n');
const newText = ['a', 'c', 'd'].join('\n');
+
expect(computeLineDiff(oldText, newText)).toEqual([
- { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
- { kind: 'remove', text: 'b', oldLine: 2 },
- { kind: 'context', text: 'c', oldLine: 3, newLine: 2 },
- { kind: 'context', text: 'd', oldLine: 4, newLine: 3 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
+ { kind: 'remove', oldLine: 2, text: 'b' },
+ { kind: 'context', newLine: 2, oldLine: 3, text: 'c' },
+ { kind: 'context', newLine: 3, oldLine: 4, text: 'd' }
]);
});
it('handles purely identical inputs', () => {
const text = 'x\ny\nz';
const result = computeLineDiff(text, text);
+
expect(result).toEqual([
- { kind: 'context', text: 'x', oldLine: 1, newLine: 1 },
- { kind: 'context', text: 'y', oldLine: 2, newLine: 2 },
- { kind: 'context', text: 'z', oldLine: 3, newLine: 3 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'x' },
+ { kind: 'context', newLine: 2, oldLine: 2, text: 'y' },
+ { kind: 'context', newLine: 3, oldLine: 3, text: 'z' }
]);
});
it('strips a trailing newline on the old/new inputs', () => {
expect(computeLineDiff('a\n', 'a\nb\n')).toEqual([
- { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
- { kind: 'add', text: 'b', newLine: 2 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
+ { kind: 'add', newLine: 2, text: 'b' }
]);
});
it('normalizes trailing CR on each line', () => {
expect(computeLineDiff('a\r\nb\r\n', 'a\nb')).toEqual([
- { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
- { kind: 'context', text: 'b', oldLine: 2, newLine: 2 }
+ { kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
+ { kind: 'context', newLine: 2, oldLine: 2, text: 'b' }
]);
});
// remove) carry no number on that side.
let lastOld = 0;
let lastNew = 0;
+
for (const line of diff) {
if (line.oldLine !== undefined) {
expect(line.oldLine).toBeGreaterThan(lastOld);
lastOld = line.oldLine;
}
+
if (line.newLine !== undefined) {
expect(line.newLine).toBeGreaterThan(lastNew);
lastNew = line.newLine;
{ kind: DiffLineKind.ADD, text: 'plus' },
{ kind: DiffLineKind.REMOVE, text: 'minus' }
];
+
expect(renderUnifiedDiff(lines)).toBe(' ctx\n+plus\n-minus');
});
it('ignores oldLine/newLine metadata when emitting prefixes', () => {
const lines: DiffLine[] = [
- { kind: DiffLineKind.CONTEXT, text: 'a', oldLine: 1, newLine: 1 },
- { kind: DiffLineKind.ADD, text: 'b', newLine: 2 }
+ { kind: DiffLineKind.CONTEXT, newLine: 1, oldLine: 1, text: 'a' },
+ { kind: DiffLineKind.ADD, newLine: 2, text: 'b' }
];
+
expect(renderUnifiedDiff(lines)).toBe(' a\n+b');
});
});
-import { describe, expect, it } from 'vitest';
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('tokenizeContent', () => {
it('tokenizes a plain text buffer with no badges', () => {
it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
{ kind: 'text', text: ' ' }
const path =
'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
{ kind: 'text', text: ' ' }
it('keeps text around a badge with spaces in the path', () => {
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'text', text: 'see ' },
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
const path = '/Users/foo/Screenshot (1).png';
const source = `[Screenshot (1).png](file://${path}) `;
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot (1).png', path },
{ kind: 'text', text: ' ' }
it('recognizes badges whose folder name is wrapped in parentheses', () => {
const path = '/Users/foo/Project (Stuff)/main.rs';
const source = `[main.rs](file://${path}) `;
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'main.rs', path },
{ kind: 'text', text: ' ' }
it('recognizes adjacent badges back-to-back with no separator', () => {
const source = '[a](file:///p)[b](file:///q)';
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'a', path: '/p' },
{ kind: 'badge', name: 'b', path: '/q' }
it('tokenizes a fenced code block without a language', () => {
const source = 'before\n```\nconst a = 1;\n```\nafter';
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'text', text: 'before\n' },
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
it('tokenizes a fenced code block with a language', () => {
const source = '```js\nconst a = 1;\n```';
+
expect(tokenizeContent(source)).toEqual([
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
]);
it('is true inside a still-open block while it is being typed', () => {
const open = '```js\nconst a = 1;';
+
expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
});
it('toggles per fence across multiple blocks', () => {
const two = BLOCK + '\ntext\n' + BLOCK;
const secondBlock = two.lastIndexOf(BLOCK);
+
expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
-import { describe, expect, it } from 'vitest';
import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
// Layout of `hello [docs](file:///a/b) world foo`:
// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
it('treats a leading badge as one word in both directions', () => {
const source = `${BADGE} rest`;
+
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
});
it('treats adjacent badges as separate words', () => {
// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
const source = '[a](file:///x)[b](file:///y)';
+
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
it('jumps over a badge following punctuation', () => {
// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
const source = `foo, ${BADGE} bar`;
+
expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
});
-import { describe, it, expect } from 'vitest';
-import { classifyContinueIntent } from '$lib/utils/agentic';
import { ContinueIntentKind, MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';
+import { classifyContinueIntent } from '$lib/utils/agentic';
+import { describe, expect, it } from 'vitest';
/**
* Tests for the Continue button intent classifier.
*/
let nextId = 0;
+
function makeMsg(role: MessageRole, opts: Partial<DatabaseMessage> = {}): DatabaseMessage {
nextId++;
+
return {
- id: `msg-${nextId}`,
- convId: 'conv-1',
- type: MessageType.TEXT,
- timestamp: nextId,
- role,
+ children: [],
content: '',
+ convId: 'conv-1',
+ id: `msg-${nextId}`,
parent: null,
- children: [],
+ role,
+ timestamp: nextId,
+ type: MessageType.TEXT,
...opts
};
}
function toolCall(id: string, name: string, args: string = '{}'): string {
- return JSON.stringify([{ id, type: 'function', function: { name, arguments: args } }]);
+ return JSON.stringify([{ function: { arguments: args, name }, id, type: 'function' }]);
}
describe('classifyContinueIntent', () => {
makeMsg(MessageRole.USER, { content: 'hello' }),
makeMsg(MessageRole.ASSISTANT, { content: 'hi there' })
];
-
const intent = classifyContinueIntent(messages, 1);
expect(intent).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
toolCalls: toolCall('call_1', 'bash_tool', '{"command":"ls"}')
})
];
-
const intent = classifyContinueIntent(messages, 1);
expect(intent).toEqual({ kind: ContinueIntentKind.RERUN_TURN, truncateAfter: 0 });
}),
makeMsg(MessageRole.TOOL, { content: 'file1\nfile2', toolCallId: 'call_1' })
];
-
const intent = classifyContinueIntent(messages, 1);
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
makeMsg(MessageRole.ASSISTANT, {
content: '',
toolCalls: JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'a', arguments: '{}' } },
- { id: 'call_2', type: 'function', function: { name: 'b', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'a' }, id: 'call_1', type: 'function' },
+ { function: { arguments: '{}', name: 'b' }, id: 'call_2', type: 'function' }
])
}),
makeMsg(MessageRole.TOOL, { content: 'r1', toolCallId: 'call_1' }),
makeMsg(MessageRole.TOOL, { content: 'r2', toolCallId: 'call_2' })
];
-
const intent = classifyContinueIntent(messages, 1);
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 3 });
makeMsg(MessageRole.USER, { content: 'wait' }),
makeMsg(MessageRole.TOOL, { content: 'late', toolCallId: 'call_1' })
];
-
const intent = classifyContinueIntent(messages, 1);
// truncateAfter must point at the contiguous tool block, not jump over
-import { beforeAll, describe, expect, it } from 'vitest';
-import { zipSync, strToU8 } from 'fflate';
-import { MessageRole, MessageType } from '$lib/enums';
import { NEWLINE } from '$lib/constants';
+import { MessageRole, MessageType } from '$lib/enums';
import type { ExportedConversation } from '$lib/types/database';
+import { strToU8, zipSync } from 'fflate';
+import { beforeAll, describe, expect, it } from 'vitest';
let conversationsStore: typeof import('$lib/stores/conversations.svelte').conversationsStore;
beforeAll(async () => {
const store = new Map<string, string>();
const polyfill: Storage = {
- get length() {
- return store.size;
- },
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
+ get length() {
+ return store.size;
+ },
removeItem: (k) => {
store.delete(k);
},
store.set(k, String(v));
}
};
+
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
({ conversationsStore } = await import('$lib/stores/conversations.svelte'));
function makeSession(id: string): ExportedConversation {
return {
- conv: { id, currNode: `${id}-msg`, lastModified: 0, name: `Chat ${id}` },
+ conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
messages: [
{
- id: `${id}-msg`,
- convId: id,
- type: MessageType.TEXT,
- timestamp: 0,
- role: MessageRole.USER,
+ children: [],
content: `hello from ${id}`,
+ convId: id,
+ id: `${id}-msg`,
parent: null,
- children: []
+ role: MessageRole.USER,
+ timestamp: 0,
+ type: MessageType.TEXT
}
]
} as unknown as ExportedConversation;
describe('conversationsStore.parseImportFile', () => {
it('imports a JSONL export whose name has no meaningful extension', async () => {
const jsonl = conversationsStore.serializeSessionToJsonl(makeSession('a'));
-
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export'));
expect(sessions).toHaveLength(1);
const jsonl = [makeSession('a'), makeSession('b')]
.map((session) => conversationsStore.serializeSessionToJsonl(session))
.join(NEWLINE);
-
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export.txt'));
expect(sessions.map((session) => session.conv.id)).toEqual(['a', 'b']);
'b.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('b'))),
'notes.txt': strToU8('ignored')
});
-
const sessions = await conversationsStore.parseImportFile(new File([zipped], 'archive'));
expect(sessions.map((session) => session.conv.id).sort()).toEqual(['a', 'b']);
it('imports the legacy JSON array format', async () => {
const json = JSON.stringify([makeSession('a')], null, 2);
-
const sessions = await conversationsStore.parseImportFile(new File([json], 'export.jsonl'));
expect(sessions).toHaveLength(1);
it('imports the legacy JSON single object format', async () => {
const json = JSON.stringify(makeSession('a'));
-
const sessions = await conversationsStore.parseImportFile(new File([json], 'export'));
expect(sessions).toHaveLength(1);
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
colorizeFaviconSvg,
padFaviconSvg,
writeThemeFavicons
} from '../../scripts/favicon-colorize';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const SOURCE_SVG = [
'<svg xmlns="http://www.w3.org/2000/svg">',
describe('colorizeFaviconSvg', () => {
it('substitutes every currentColor occurrence for the light variant', () => {
const { light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+
expect(light.match(/currentColor/g)).toBeNull();
expect(light).toContain('fill="#111111"');
expect(light).toContain('<circle fill="#111111"/>');
it('substitutes every currentColor occurrence for the dark variant', () => {
const { dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+
expect(dark.match(/currentColor/g)).toBeNull();
expect(dark).toContain('fill="#fafafa"');
expect(dark).toContain('<circle fill="#fafafa"/>');
});
it('leaves non-currentColor colors untouched in both variants', () => {
- const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+
expect(light).toContain('fill="#ff00aa"');
expect(dark).toContain('fill="#ff00aa"');
});
it('does not alter any other part of the SVG', () => {
- const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
const stripColors = (s: string) =>
s.replaceAll('#111111', '').replaceAll('#fafafa', '').replaceAll('currentColor', '');
const expected = stripColors(SOURCE_SVG);
+
expect(stripColors(light)).toBe(expected);
expect(stripColors(dark)).toBe(expected);
});
it('returns the same SVG for light and dark when called with the same color', () => {
const result = colorizeFaviconSvg(SOURCE_SVG, '#abcdef', '#abcdef');
+
expect(result.light).toBe(result.dark);
});
it('returns the source unchanged when given a color that does not appear (no currentColor in source)', () => {
const plain = '<svg><path fill="#000"/></svg>';
- const { light, dark } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
+ const { dark, light } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
+
expect(light).toBe(plain);
expect(dark).toBe(plain);
});
it('wraps inner content in a translate-then-scale group that matches padding', () => {
const padded = padFaviconSvg(SIZED_SVG, 0.05);
+
// scale = 1 - 0.05 = 0.95
// translate = (0.05 * 512) / 2 = 12.8 on each axis
expect(padded).toContain('transform="translate(12.8 12.8) scale(0.95)"');
it('preserves the outer <svg> tag attributes', () => {
const padded = padFaviconSvg(SIZED_SVG, 0.1);
+
expect(padded.startsWith('<svg width="512" height="512" viewBox="0 0 512 512"')).toBe(true);
});
it('returns the input unchanged when no viewBox is present', () => {
const noViewBox = '<svg width="32" height="32"><path d="M0 0Z"/></svg>';
+
expect(padFaviconSvg(noViewBox, 0.1)).toBe(noViewBox);
});
it('returns the input unchanged when viewBox values are not finite numbers', () => {
const bad = '<svg viewBox="auto auto 0 0"><path/></svg>';
+
expect(padFaviconSvg(bad, 0.1)).toBe(bad);
});
it('tolerates a non-square viewBox', () => {
const wide = '<svg viewBox="0 0 100 50"><rect/></svg>';
const padded = padFaviconSvg(wide, 0.1);
+
// scale 0.9, translate (5, 2.5)
expect(padded).toContain('transform="translate(5 2.5) scale(0.9)"');
});
});
afterEach(() => {
- rmSync(tmpDir, { recursive: true, force: true });
+ rmSync(tmpDir, { force: true, recursive: true });
});
function setupSource() {
const sourcePath = join(tmpDir, 'logo.svg');
+
writeFileSync(sourcePath, LOGO);
+
return {
- sourcePath,
+ darkPath: join(tmpDir, 'favicon-dark.svg'),
lightPath: join(tmpDir, 'favicon.svg'),
- darkPath: join(tmpDir, 'favicon-dark.svg')
+ sourcePath
};
}
it('writes colorized, un-padded favicons without modifying the source', () => {
- const { sourcePath, lightPath, darkPath } = setupSource();
+ const { darkPath, lightPath, sourcePath } = setupSource();
const before = readFileSync(sourcePath, 'utf-8');
writeThemeFavicons('#abcdef', '#012345', {
- sourcePath,
+ darkOutPath: darkPath,
lightOutPath: lightPath,
- darkOutPath: darkPath
+ sourcePath
});
const lightOut = readFileSync(lightPath, 'utf-8');
});
it('writes colorized favicons wrapped in a padding <g transform>...</g>', () => {
- const { sourcePath, lightPath, darkPath } = setupSource();
+ const { darkPath, lightPath, sourcePath } = setupSource();
// mirror the production wiring: PWA_ASSET_GENERATOR.FAVICON_PADDING
const padding = 0.04;
// scale = 1 - 0.04 = 0.96; translate = (0.04 * 512) / 2 = 10.24
const expectedTransform = 'transform="translate(10.24 10.24) scale(0.96)"';
writeThemeFavicons('#111111', '#fafafa', {
- sourcePath,
- lightOutPath: lightPath,
darkOutPath: darkPath,
- padding
+ lightOutPath: lightPath,
+ padding,
+ sourcePath
});
const lightOut = readFileSync(lightPath, 'utf-8');
ToolsService: { executeToolRaw: vi.fn() }
}));
-import { ToolsService } from '$lib/services/tools.service';
import { GlobSearchType } from '$lib/enums';
+import { ToolsService } from '$lib/services/tools.service';
import { runGlobSearchWithChildren } from '$lib/utils';
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
50,
new AbortController().signal
);
+
expect(res.error).toBeUndefined();
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
expect(res.exactDir).toBeUndefined();
3,
50,
new AbortController().signal,
- { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+ { descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
);
+
expect(res.error).toBeUndefined();
expect(res.exactDir).toBe('/Users/rootB/src');
expect(res.entries.map((e) => e.path)).toEqual([
3,
50,
new AbortController().signal,
- { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+ { descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
);
+
expect(res.exactDir).toBeUndefined();
expect(mockExecute).toHaveBeenCalledTimes(1);
});
new AbortController().signal,
{ type: GlobSearchType.DIR }
);
+
expect(res.exactDir).toBe('/Users/rootD/src');
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
expect(mockExecute).toHaveBeenCalledTimes(2);
50,
new AbortController().signal
);
+
expect(res.error).toBe('boom');
expect(res.entries).toEqual([]);
expect(mockExecute).toHaveBeenCalledTimes(1);
-import { describe, expect, it } from 'vitest';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
+import { describe, expect, it } from 'vitest';
/**
* Tests for the header serialization helpers used by the MCP server form
// Object key order follows insertion order in modern JS engines, so
// the serialized JSON writes keys in our input order.
- expect(JSON.parse(serialized)).toEqual({ 'X-C': '3', 'X-A': '1', 'X-B': '2' });
+ expect(JSON.parse(serialized)).toEqual({ 'X-A': '1', 'X-B': '2', 'X-C': '3' });
});
});
'Content-Type': 'application/json',
'X-Trace-Id': 'abc-123'
});
-
const roundtrip = serializeHeaders(parseHeadersToArray(original));
expect(JSON.parse(roundtrip)).toEqual(JSON.parse(original));
it('preserves upstream keys untouched (does not lowercase them)', () => {
const upperCased = '{"Authorization":"Bearer xyz"}';
-
const parsed = parseHeadersToArray(upperCased);
expect(parsed).toEqual([{ key: 'Authorization', value: 'Bearer xyz' }]);
{ key: 'X-Trace-Id', value: 'abc-123' },
{ key: 'Authorization', value: 'Bearer super-secret' }
];
-
const serialized = serializeHeaders(pairs);
expect(serialized).toBe('{"X-Trace-Id":"abc-123","Authorization":"Bearer super-secret"}');
-import { describe, expect, it } from 'vitest';
import { getJpegOrientationFromDataURL, isJpegMimeType } from '$lib/utils/jpeg-orientation';
+import { describe, expect, it } from 'vitest';
// Builds the TIFF payload of an APP1 segment holding a single IFD0 entry
function buildTiff(littleEndian: boolean, tag: number, value: number): number[] {
if (tiff) {
const payload = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00, ...tiff];
const length = payload.length + 2;
+
bytes.push(0xff, 0xe1, length >> 8, length & 0xff, ...payload);
}
it('returns 1 for a payload that is not a JPEG', () => {
const png = btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a));
+
expect(getJpegOrientationFromDataURL(`data:image/png;base64,${png}`)).toBe(1);
});
it('returns 1 for a truncated payload', () => {
const truncated = btoa(String.fromCharCode(0xff, 0xd8, 0xff));
+
expect(getJpegOrientationFromDataURL(`data:image/jpeg;base64,${truncated}`)).toBe(1);
});
/* eslint-disable no-irregular-whitespace */
-import { describe, it, expect, test } from 'vitest';
import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
+import { describe, expect, it, test } from 'vitest';
describe('maskInlineLaTeX', () => {
it('should protect LaTeX $x + y$ but not money $3.99', () => {
const input =
'\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).';
const output = preprocessLaTeX(input);
+
expect(output).toBe(
'$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.'
);
const input =
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).';
const output = preprocessLaTeX(input);
+
expect(output).toBe(
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.'
);
\\end{aligned}}
\\]`;
const output = preprocessLaTeX(input);
+
expect(output).toBe(
`$$
\\boxed{
\mathbb{Z}_2 & n = 4 \\
\end{cases}$$`;
const output = preprocessLaTeX(input);
+
// If the formula contains '\\' the $$-delimiters should be in their own line.
expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$:
$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases}
+import { CONFIG_LOCALSTORAGE_KEY, STORAGE_APP_NAME } from '$lib/constants';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
-import { STORAGE_APP_NAME, CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
// node env unit project has no DOM, install a minimal localStorage backed by a Map
beforeAll(() => {
const store = new Map<string, string>();
const polyfill: Storage = {
- get length() {
- return store.size;
- },
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
+ get length() {
+ return store.size;
+ },
removeItem: (k) => {
store.delete(k);
},
store.set(k, String(v));
}
};
+
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
});
async function runMigrations() {
const { MigrationService } = await import('$lib/services/migration.service');
+
await MigrationService.runAllMigrations();
}
it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => {
writeConfig({
- mcpServers: JSON.stringify([
- { id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' },
- { id: 'hf', enabled: false, url: 'https://huggingface.co/mcp' }
- ]),
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
- { serverId: 'exa', enabled: true },
- { serverId: 'hf', enabled: false }
+ { enabled: true, serverId: 'exa' },
+ { enabled: false, serverId: 'hf' }
+ ]),
+ mcpServers: JSON.stringify([
+ { enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' },
+ { enabled: false, id: 'hf', url: 'https://huggingface.co/mcp' }
])
});
it('skips override ids that do not match any configured server', async () => {
writeConfig({
- mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
- { serverId: 'orphan', enabled: true },
- { serverId: 'exa', enabled: true }
- ])
+ { enabled: true, serverId: 'orphan' },
+ { enabled: true, serverId: 'exa' }
+ ]),
+ mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
});
await runMigrations();
it('is a no-op when there are no legacy overrides', async () => {
writeConfig({
- mcpServers: JSON.stringify([{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }])
+ mcpServers: JSON.stringify([{ enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
});
await runMigrations();
it('does not rewrite mcpServers when override.enabled already matches', async () => {
const originalServers = JSON.stringify([
- { id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }
+ { enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }
]);
writeConfig({
- mcpServers: originalServers,
- [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
+ mcpServers: originalServers
});
await runMigrations();
const after = readConfig();
+
expect(after.mcpServers).toBe(originalServers);
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
});
it('records itself as completed so subsequent loads do not re-run', async () => {
writeConfig({
- mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
- [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
+ [MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
+ mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
});
const { MigrationService } = await import('$lib/services/migration.service');
await MigrationService.runAllMigrations();
const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY);
+
expect(stateRaw).not.toBeNull();
const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] };
+
expect(state.completed).toContain('mcp-default-overrides-merge-v1');
expect(state.failed).not.toContain('mcp-default-overrides-merge-v1');
});
-import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import type { DatabaseConversation } from '$lib/types/database';
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// node env unit project has no DOM, install a minimal localStorage backed by a Map
beforeAll(() => {
const store = new Map<string, string>();
const polyfill: Storage = {
- get length() {
- return store.size;
- },
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
+ get length() {
+ return store.size;
+ },
removeItem: (k) => {
store.delete(k);
},
store.set(k, String(v));
}
};
+
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
});
CONFIG_LOCALSTORAGE_KEY,
JSON.stringify({
[SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
- { id: 'alpha', enabled: false, url: 'https://alpha.example.com/mcp' },
- { id: 'bravo', enabled: true, url: 'https://bravo.example.com/mcp' }
+ { enabled: false, id: 'alpha', url: 'https://alpha.example.com/mcp' },
+ { enabled: true, id: 'bravo', url: 'https://bravo.example.com/mcp' }
])
})
);
const { settingsStore } = await import('$lib/stores/settings.svelte');
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
const saved = JSON.parse(raw) as Record<string, unknown>;
+
settingsStore.config = {
...settingsStore.config,
[SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
overrides?: { serverId: string; enabled: boolean }[]
): DatabaseConversation {
return {
- id: 'conv-1',
currNode: null,
+ id: 'conv-1',
lastModified: 0,
- name: 'Test chat',
- mcpServerOverrides: overrides
+ mcpServerOverrides: overrides,
+ name: 'Test chat'
};
}
it('inherits server.enabled when no conversation is active', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = null;
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
it('inherits server.enabled on a newly created chat with no overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = makeConversation();
// Empty override list: must fall back to global server.enabled, not all-off.
it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = makeConversation(undefined);
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
// Override flips bravo off for this chat, alpha keeps its global default.
conversationsStore.activeConversation = makeConversation([
- { serverId: 'bravo', enabled: false }
+ { enabled: false, serverId: 'bravo' }
]);
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = makeConversation([
- { serverId: 'alpha', enabled: true }
+ { enabled: true, serverId: 'alpha' }
]);
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
- { serverId: 'alpha', enabled: true },
- { serverId: 'bravo', enabled: true }
+ { enabled: true, serverId: 'alpha' },
+ { enabled: true, serverId: 'bravo' }
]);
});
it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = makeConversation();
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
- { serverId: 'alpha', enabled: false },
- { serverId: 'bravo', enabled: true }
+ { enabled: false, serverId: 'alpha' },
+ { enabled: true, serverId: 'bravo' }
]);
});
it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+
conversationsStore.activeConversation = makeConversation([
- { serverId: 'alpha', enabled: true }
+ { enabled: true, serverId: 'alpha' }
]);
expect(conversationsStore.getMcpServerOverride('bravo')).toEqual({
- serverId: 'bravo',
- enabled: true
+ enabled: true,
+ serverId: 'bravo'
});
});
});
-import { describe, expect, it } from 'vitest';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+import { describe, expect, it } from 'vitest';
/**
* Default-value policy for the `MCP_SERVERS` setting.
-import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client';
-import { MCPService } from '$lib/services/mcp.service';
+import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
+import { MCPService } from '$lib/services/mcp.service';
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
-import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
+import { afterEach, describe, expect, it, vi } from 'vitest';
type DiagnosticFetchFactory = (
serverName: string,
it('stops transport phase logging after handshake diagnostics are disabled', async () => {
const logs: MCPConnectionLog[] = [];
const response = new Response('{}', {
- status: 200,
- headers: { 'content-type': 'application/json' }
+ headers: { 'content-type': 'application/json' },
+ status: 200
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
- transport: MCPTransportType.STREAMABLE_HTTP
+ transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp'
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
- await controller.fetch(config.url, { method: 'POST', body: '{}' });
+ await controller.fetch(config.url, { body: '{}', method: 'POST' });
expect(logs).toHaveLength(2);
expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
controller.disable();
- await controller.fetch(config.url, { method: 'POST', body: '{}' });
+ await controller.fetch(config.url, { body: '{}', method: 'POST' });
expect(logs).toHaveLength(2);
});
it('redacts all configured custom headers in diagnostic request logs', async () => {
const logs: MCPConnectionLog[] = [];
const response = new Response('{}', {
- status: 200,
- headers: { 'content-type': 'application/json' }
+ headers: { 'content-type': 'application/json' },
+ status: 200
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
- transport: MCPTransportType.STREAMABLE_HTTP,
headers: {
'x-auth-token': 'secret-token',
'x-vendor-api-key': 'secret-key'
- }
+ },
+ transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp'
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
headers: config.headers
});
await controller.fetch(config.url, {
- method: 'POST',
+ body: '{}',
headers: { 'content-type': 'application/json' },
- body: '{}'
+ method: 'POST'
});
expect(logs).toHaveLength(2);
expect(logs[0].details).toMatchObject({
request: {
headers: {
+ 'content-type': 'application/json',
'x-auth-token': '[redacted]',
- 'x-vendor-api-key': '[redacted]',
- 'content-type': 'application/json'
+ 'x-vendor-api-key': '[redacted]'
}
}
});
const proxiedContentType = `${CORS_PROXY_HEADER_PREFIX}content-type`;
const proxiedSessionId = `${CORS_PROXY_HEADER_PREFIX}mcp-session-id`;
const response = new Response('{}', {
- status: 200,
- headers: { 'content-type': 'application/json' }
+ headers: { 'content-type': 'application/json' },
+ status: 200
});
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal('fetch', fetchMock);
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp',
useProxy: true
};
-
const controller = createDiagnosticFetch(
config,
(log) => logs.push(log),
);
await controller.fetch('http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp', {
- method: 'POST',
+ body: '{}',
headers: {
'content-type': 'application/json',
'mcp-session-id': 'session-request-12345'
},
- body: '{}'
+ method: 'POST'
});
const sentHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
+
expect(sentHeaders.get('authorization')).toBe('Bearer llama-server-key');
expect(sentHeaders.get(proxiedAuthToken)).toBe('target-token');
expect(sentHeaders.get(proxiedContentType)).toBe('application/json');
vi.stubGlobal('fetch', fetchMock);
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp',
useProxy: true
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {}, true);
-
const response = await controller.fetch(
'http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp',
{ method: 'DELETE' }
expect(fetchMock).not.toHaveBeenCalled();
expect(response.status).toBe(200);
expect(logs.at(-1)?.details).toMatchObject({
- response: { status: 200, isFake: true }
+ response: { isFake: true, status: 200 }
});
});
it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
const logs: MCPConnectionLog[] = [];
const response = new Response('{}', {
- status: 200,
headers: {
'content-type': 'application/json',
'mcp-session-id': 'session-response-67890'
- }
+ },
+ status: 200
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
- transport: MCPTransportType.STREAMABLE_HTTP
+ transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp'
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
await controller.fetch(config.url, {
- method: 'POST',
+ body: '{}',
headers: {
'content-type': 'application/json',
'mcp-session-id': 'session-request-12345'
},
- body: '{}'
+ method: 'POST'
});
expect(logs).toHaveLength(2);
it('extracts JSON-RPC methods without logging the raw request body', async () => {
const logs: MCPConnectionLog[] = [];
const response = new Response('{}', {
- status: 200,
- headers: { 'content-type': 'application/json' }
+ headers: { 'content-type': 'application/json' },
+ status: 200
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
const config: MCPServerConfig = {
- url: 'https://example.com/mcp',
- transport: MCPTransportType.STREAMABLE_HTTP
+ transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'https://example.com/mcp'
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
await controller.fetch(config.url, {
- method: 'POST',
body: JSON.stringify([
- { jsonrpc: '2.0', id: 1, method: 'initialize' },
+ { id: 1, jsonrpc: '2.0', method: 'initialize' },
{ jsonrpc: '2.0', method: 'notifications/initialized' }
- ])
+ ]),
+ method: 'POST'
});
expect(logs[0].details).toMatchObject({
request: {
- method: 'POST',
body: {
kind: 'string',
size: expect.any(Number)
},
- jsonRpcMethods: ['initialize', 'notifications/initialized']
+ jsonRpcMethods: ['initialize', 'notifications/initialized'],
+ method: 'POST'
}
});
});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
const config: MCPServerConfig = {
- url: 'http://localhost:8000/mcp',
- transport: MCPTransportType.STREAMABLE_HTTP
+ transport: MCPTransportType.STREAMABLE_HTTP,
+ url: 'http://localhost:8000/mcp'
};
-
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
- await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).rejects.toThrow(
+ await expect(controller.fetch(config.url, { body: '{}', method: 'POST' })).rejects.toThrow(
'Failed to fetch'
);
it('detaches phase error logging after the initialize handshake completes', async () => {
const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = [];
const stopPhaseLogging = vi.fn();
+
let emitClientError: ((error: Error) => void) | undefined;
vi.spyOn(MCPService, 'createTransport').mockReturnValue({
+ stopPhaseLogging,
transport: {} as never,
- type: MCPTransportType.WEBSOCKET,
- stopPhaseLogging
+ type: MCPTransportType.WEBSOCKET
});
vi.spyOn(MCPService, 'listTools').mockResolvedValue([]);
vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined);
await MCPService.connect(
'test-server',
{
- url: 'ws://example.com/mcp',
- transport: MCPTransportType.WEBSOCKET
+ transport: MCPTransportType.WEBSOCKET,
+ url: 'ws://example.com/mcp'
},
undefined,
undefined,
- (phase, log) => phaseLogs.push({ phase, log })
+ (phase, log) => phaseLogs.push({ log, phase })
);
expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
expect(
phaseLogs.filter(
- ({ phase, log }) =>
+ ({ log, phase }) =>
phase === MCPConnectionPhase.ERROR &&
log.message === 'Protocol error: handshake protocol error'
)
expect(
phaseLogs.filter(
- ({ phase, log }) =>
+ ({ log, phase }) =>
phase === MCPConnectionPhase.ERROR &&
log.message === 'Protocol error: runtime protocol error'
)
-import { describe, expect, it } from 'vitest';
+import { FileMentionEntryType } from '$lib/enums';
import {
- MENTION_BADGE_FILE_ICON_PATHS,
- MENTION_BADGE_FOLDER_ICON_PATHS,
buildMentionInsertion,
containsFileMentionLink,
decodeFileLinkPath,
encodeFileLinkPath,
fileMentionLinkRe,
getMentionBadgeIconPaths,
- getMentionBadgeLabel
+ getMentionBadgeLabel,
+ MENTION_BADGE_FILE_ICON_PATHS,
+ MENTION_BADGE_FOLDER_ICON_PATHS
} from '$lib/utils';
-import { FileMentionEntryType } from '$lib/enums';
+import { describe, expect, it } from 'vitest';
describe('encodeFileLinkPath', () => {
it('leaves a clean path unchanged', () => {
const match = fileMentionLinkRe().exec(
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
);
+
expect(match).not.toBeNull();
expect(match?.[1]).toBe('Screenshot (1).png');
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
describe('buildMentionInsertion', () => {
const file = (path: string, name: string) => ({
- path,
name,
+ path,
type: FileMentionEntryType.FILE
});
const dir = (path: string, name: string) => ({
- path,
name,
+ path,
type: FileMentionEntryType.DIRECTORY
});
it('splices a root-anchored file link in place of the token', () => {
const value = 'hello @repo';
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
- start: 6,
- end: 11
+ end: 11,
+ start: 6
});
+
expect(result).not.toBeNull();
- const { newValue, caretOffset } = result!;
+ const { caretOffset, newValue } = result!;
+
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
});
it('keeps the trailing slash on the directory marker', () => {
const value = 'see @src';
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
- start: 4,
- end: 8
+ end: 8,
+ start: 4
})!;
+
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
});
const { newValue } = buildMentionInsertion(
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
value,
- { start: 0, end: 4 }
+ { end: 4, start: 0 }
)!;
+
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
});
it('re-adds the directory marker when the cleaned path empties', () => {
- const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
+ const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { end: 1, start: 0 })!;
+
expect(newValue).toBe('[root](file:///) ');
});
it('returns null for an out-of-range token', () => {
- expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
- expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
+ expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 5, start: 0 })).toBeNull();
+ expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 1, start: 2 })).toBeNull();
});
});
-import { describe, expect, it } from 'vitest';
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('findMentionToken', () => {
it('returns null for an empty/bare cursor', () => {
});
it('recognizes a mention at the start of the value', () => {
- expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
+ expect(findMentionToken('@pr', 3)).toEqual({ end: 3, query: 'pr', start: 0 });
});
it('recognizes a mention after a word boundary', () => {
- expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
+ expect(findMentionToken('hello @pr', 9)).toEqual({ end: 9, query: 'pr', start: 6 });
});
it('returns null when the @ is mid-identifier', () => {
});
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
- expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
- expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
- expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
+ expect(findMentionToken('(@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
+ expect(findMentionToken('[@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
+ expect(findMentionToken('a,@pr', 5)).toEqual({ end: 5, query: 'pr', start: 2 });
});
it('does not treat an identifier character as a boundary', () => {
});
it('extracts the whole token up to the trailing boundary as the query', () => {
- expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
- expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
+ expect(findMentionToken('@', 1)).toEqual({ end: 1, query: '', start: 0 });
+ expect(findMentionToken('@hello', 6)).toEqual({ end: 6, query: 'hello', start: 0 });
});
it('keeps the whole token as the query when the caret is mid-token', () => {
- expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
- expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
+ expect(findMentionToken('@hello', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
+ expect(findMentionToken('@hello world', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
});
it('ignores a boundary @ and keeps the most recent token', () => {
- expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
+ expect(findMentionToken('a @foo @bar', 11)).toEqual({ end: 11, query: 'bar', start: 7 });
});
});
it('captures start and query of the current mention', () => {
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
- start: 6,
- query: 'proj'
+ query: 'proj',
+ start: 6
});
});
});
-import { describe, expect, it } from 'vitest';
import { ModelsService } from '$lib/services/models.service';
+import { describe, expect, it } from 'vitest';
const { parseModelId } = ModelsService;
it('handles ambiguous model names', () => {
// Qwen3.5 Instruct vs Thinking — tags should distinguish them
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({
+ activatedParams: 'A3B',
modelName: 'Qwen3.5',
params: '30B',
- activatedParams: 'A3B',
tags: ['Instruct']
});
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({
+ activatedParams: 'A3B',
modelName: 'Qwen3.5',
params: '30B',
- activatedParams: 'A3B',
tags: ['Thinking']
});
-import { describe, expect, it } from 'vitest';
import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
+import { describe, expect, it } from 'vitest';
describe('normalizeModelName', () => {
it('preserves Hugging Face org/model format (single slash)', () => {
-import { describe, expect, it } from 'vitest';
import { isExitCodeSummaryLine, parseExecShellCommandExitStatus } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('parseExecShellCommandExitStatus', () => {
it('returns undefined when result is empty', () => {
it('parses a zero-exit summary at end of clean stdout', () => {
const status = parseExecShellCommandExitStatus('hello world\n[exit code: 0]');
+
expect(status).toEqual({
code: 0,
- timedOut: false,
- rawText: '[exit code: 0]'
+ rawText: '[exit code: 0]',
+ timedOut: false
});
});
it('parses a non-zero exit summary', () => {
const status = parseExecShellCommandExitStatus('cargo: error[E0425]\n[exit code: 101]');
+
expect(status?.code).toBe(101);
expect(status?.timedOut).toBe(false);
});
const status = parseExecShellCommandExitStatus(
'still building...\n[exit code: -1] [exit due to timed out]'
);
+
expect(status?.code).toBe(-1);
expect(status?.timedOut).toBe(true);
});
it('tolerates trailing whitespace after the tail line', () => {
const status = parseExecShellCommandExitStatus('[exit code: 0] \n\n');
+
expect(status?.code).toBe(0);
});
const status = parseExecShellCommandExitStatus(
'the shell prints [exit code: 0]\nwhen done\nreally done\n'
);
+
expect(status).toBeUndefined();
});
it('does not match mid-stream exit lines followed by more output', () => {
const status = parseExecShellCommandExitStatus('[exit code: 0]\nmore output keeps streaming');
+
expect(status).toBeUndefined();
});
});
-import { describe, expect, it, vi } from 'vitest';
-import { parseMcpServerSettings } from '$lib/utils/mcp';
import { MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
+import { parseMcpServerSettings } from '$lib/utils/mcp';
+import { describe, expect, it, vi } from 'vitest';
/**
* Tests for the mcpServers settings parser.
it('drops entries with no parseable id and substitutes a stable fallback', () => {
const parsed = parseMcpServerSettings(
- JSON.stringify([{ url: 'https://a.test', enabled: true }, { url: 'https://b.test' }])
+ JSON.stringify([{ enabled: true, url: 'https://a.test' }, { url: 'https://b.test' }])
);
expect(parsed).toHaveLength(2);
// making the Settings value a no-op for existing servers. The
// parser drops the field so the global applies live everywhere.
const parsed = parseMcpServerSettings(
- JSON.stringify([{ id: 'a', url: 'https://a.test', requestTimeoutSeconds: 45 }])
+ JSON.stringify([{ id: 'a', requestTimeoutSeconds: 45, url: 'https://a.test' }])
);
expect(parsed[0]).not.toHaveProperty('requestTimeoutSeconds');
it('treats whitespace-only headers strings as undefined', () => {
const parsed = parseMcpServerSettings(
JSON.stringify([
- { id: 'a', url: 'https://a.test', headers: ' ' },
- { id: 'b', url: 'https://b.test', headers: '{"X-Foo":"bar"}' }
+ { headers: ' ', id: 'a', url: 'https://a.test' },
+ { headers: '{"X-Foo":"bar"}', id: 'b', url: 'https://b.test' }
])
);
const parsed = parseMcpServerSettings(
JSON.stringify([
{ id: 'a', url: 'https://a.test' },
- { id: 'b', url: 'https://b.test', enabled: true },
- { id: 'c', url: 'https://c.test', enabled: false },
+ { enabled: true, id: 'b', url: 'https://b.test' },
+ { enabled: false, id: 'c', url: 'https://c.test' },
{ id: 'd', url: 'https://d.test', useProxy: true }
])
);
// contain disabled entries.
const parsed = parseMcpServerSettings(
JSON.stringify([
- { id: 'on', url: 'https://on.test', enabled: true },
- { id: 'off', url: 'https://off.test', enabled: false }
+ { enabled: true, id: 'on', url: 'https://on.test' },
+ { enabled: false, id: 'off', url: 'https://off.test' }
])
);
{ id: 'alpha', url: 'https://a.test' },
{ id: 'beta', url: 'https://b.test' }
];
-
const parsed = parseMcpServerSettings(JSON.stringify(source));
expect(parsed.map((entry) => entry.id)).toEqual(['gamma', 'alpha', 'beta']);
it('passes non-string raw input through the JSON-equality path', () => {
const parsed = parseMcpServerSettings([
{ id: 'a', url: 'https://a.test' },
- { id: 'b', url: 'https://b.test', enabled: true }
+ { enabled: true, id: 'b', url: 'https://b.test' }
]);
expect(parsed).toHaveLength(2);
// streaming text tokens trigger redundant JSON.parse calls on unchanged
// tool call data.
-import { describe, it, expect, vi } from 'vitest';
-import { deriveAgenticSections } from '$lib/utils/agentic';
+import { AgenticSectionType, MessageRole } from '$lib/enums';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type { DatabaseMessage } from '$lib/types/database';
-import { MessageRole, AgenticSectionType } from '$lib/enums';
+import { deriveAgenticSections } from '$lib/utils/agentic';
+import { describe, expect, it, vi } from 'vitest';
function makeMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
return {
- id: 'm1',
- convId: 'c1',
- type: 'text',
- timestamp: 0,
- role: MessageRole.ASSISTANT,
+ children: [],
content: '',
+ convId: 'c1',
+ id: 'm1',
parent: null,
- children: [],
+ role: MessageRole.ASSISTANT,
+ timestamp: 0,
+ type: 'text',
...overrides
} as DatabaseMessage;
}
// re-parse (which we verify by checking the returned sections
// are equivalent).
const toolCallsJson = JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
]);
-
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const sections1 = deriveAgenticSections(msg, [], [], false);
const sections2 = deriveAgenticSections(msg, [], [], false);
it('does not re-parse JSON on cache hit', () => {
const toolCallsJson = JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
]);
-
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const spy = vi.spyOn(JSON, 'parse');
describe('deriveAgenticSections O(1) tool message lookup', () => {
it('matches tool messages to tool calls by toolCallId', () => {
const toolCallsJson = JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'test_1', arguments: '{}' } },
- { id: 'call_2', type: 'function', function: { name: 'test_2', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'test_1' }, id: 'call_1', type: 'function' },
+ { function: { arguments: '{}', name: 'test_2' }, id: 'call_2', type: 'function' }
]);
-
const toolMessages = [
- makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_1', content: 'result_1' }),
- makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_2', content: 'result_2' })
+ makeMessage({ content: 'result_1', role: MessageRole.TOOL, toolCallId: 'call_1' }),
+ makeMessage({ content: 'result_2', role: MessageRole.TOOL, toolCallId: 'call_2' })
];
-
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const sections = deriveAgenticSections(msg, toolMessages, [], false);
-
// Expect: TEXT + 2 TOOL_CALL sections
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
+
expect(toolCallSections).toHaveLength(2);
expect(toolCallSections[0].toolResult).toBe('result_1');
expect(toolCallSections[1].toolResult).toBe('result_2');
it('handles missing tool messages (pending calls during streaming)', () => {
const toolCallsJson = JSON.stringify([
- { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
+ { function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
]);
-
const msg = makeMessage({ content: '', toolCalls: toolCallsJson });
const sections = deriveAgenticSections(msg, [], [], true);
-
const toolCallSection = sections.find((s) => s.type === AgenticSectionType.TOOL_CALL_PENDING);
+
expect(toolCallSection).toBeDefined();
expect(toolCallSection?.content).toBe('');
});
const toolCalls = Array.from(
{ length: N },
(_, i): ApiChatCompletionToolCall => ({
+ function: { arguments: '{}', name: `tool_${i}` },
id: `call_${i}`,
- type: 'function',
- function: { name: `tool_${i}`, arguments: '{}' }
+ type: 'function'
})
);
const toolCallsJson = JSON.stringify(toolCalls);
-
const toolMessages = Array.from({ length: N }, (_, i) =>
makeMessage({
+ content: `result_${i}`,
role: MessageRole.TOOL,
- toolCallId: `call_${i}`,
- content: `result_${i}`
+ toolCallId: `call_${i}`
})
);
-
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
-
// If the lookup were still O(n^2), this would be noticeably slow
const start = Date.now();
const sections = deriveAgenticSections(msg, toolMessages, [], false);
const elapsed = Date.now() - start;
-
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
+
expect(toolCallSections).toHaveLength(N);
expect(elapsed).toBeLessThan(100); // Should be fast with O(1) lookup
});
-import { describe, it, expect } from 'vitest';
import { MessageRole } from '$lib/enums';
-import { deriveAgenticSections } from '$lib/utils/agentic';
import type { DatabaseMessage } from '$lib/types/database';
+import { deriveAgenticSections } from '$lib/utils/agentic';
+import { describe, expect, it } from 'vitest';
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
- id: overrides.id ?? 'ast-1',
- convId: 'conv-1',
- type: 'text',
- timestamp: Date.now(),
- role: MessageRole.ASSISTANT,
+ children: [],
content: overrides.content ?? '',
+ convId: 'conv-1',
+ id: overrides.id ?? 'ast-1',
parent: null,
- children: [],
+ role: MessageRole.ASSISTANT,
+ timestamp: Date.now(),
+ type: 'text',
...overrides
} as DatabaseMessage;
}
// onAssistantTurnComplete(...undefined).
function buildApiToolCalls(message: DatabaseMessage): unknown[] | undefined {
if (!message.toolCalls) return undefined;
+
try {
const parsed = JSON.parse(message.toolCalls);
+
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
} catch {
return undefined;
content: 'partial reasoning',
toolCalls: JSON.stringify([
{
- id: 'call_1',
- type: 'function',
function: {
- name: 'exec_shell_command',
- arguments: '{"command":`grep -n \\"read_to\\" ` /Users'
- }
+ arguments: '{"command":`grep -n \\"read_to\\" ` /Users',
+ name: 'exec_shell_command'
+ },
+ id: 'call_1',
+ type: 'function'
}
])
});
-
const apiToolCalls = buildApiToolCalls(message);
// The bug: even though arguments are invalid, the outer array parses and
// own for the server to execute the tool.
expect(apiToolCalls).toBeDefined();
const args = (apiToolCalls![0] as { function: { arguments: string } }).function.arguments;
+
expect(() => JSON.parse(args)).toThrow();
});
content: 'partial reasoning',
toolCalls: ''
});
-
const apiToolCalls = buildApiToolCalls(clearedMessage);
+
expect(apiToolCalls).toBeUndefined();
});
reasoningContent: 'thinking about read_to',
toolCalls: ''
});
-
const sections = deriveAgenticSections(cleared);
+
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe('reasoning');
expect(sections.some((s) => s.type.includes('tool_call'))).toBe(false);
-import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
if (!distExists) {
console.warn(`⚠ Skipping PWA Build Output tests - dist/ not found (run 'npm run build' first)`);
it('skipped - dist/ not found', () => {});
+
return;
}
it('workbox library exists (hashed filename)', () => {
// SvelteKit generates workbox-{hash}.js files
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
+
expect(files.length).toBeGreaterThan(0);
});
it('SvelteKit bundle.js exists in _app/immutable/', () => {
// SvelteKit generates hashed bundle names in _app/immutable/
const appDir = resolve(DIST_DIR, '_app', 'immutable');
+
expect(existsSync(appDir), '_app/immutable/ not found').toBeTruthy();
const files = readdirSync(appDir).filter((f) => f.startsWith('bundle.') && f.endsWith('.js'));
+
expect(files.length).toBeGreaterThan(0);
});
it('SvelteKit bundle.css exists in _app/immutable/assets/', () => {
// SvelteKit generates hashed CSS bundles in _app/immutable/assets/
const cssDir = resolve(DIST_DIR, '_app', 'immutable', 'assets');
+
expect(existsSync(cssDir), '_app/immutable/assets/ not found').toBeTruthy();
const files = readdirSync(cssDir).filter(
(f) => f.startsWith('bundle.') && f.endsWith('.css')
);
+
expect(files.length).toBeGreaterThan(0);
});
it('has valid JSON with version field', () => {
const content = readFileSync(resolve(DIST_DIR, '_app', 'version.json'), 'utf-8');
const parsed = JSON.parse(content);
+
expect(parsed).toHaveProperty('version');
expect(typeof parsed.version).toBe('string');
expect(parsed.version.length).toBeGreaterThan(0);
describe('Hashed workbox files', () => {
it('workbox-*.js files exist in dist root (SvelteKit build output)', () => {
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
+
expect(files.length).toBeGreaterThan(0);
});
});
-import { describe, it, expect } from 'vitest';
import { MessageRole } from '$lib/enums';
+import { describe, expect, it } from 'vitest';
/**
* Tests for the new reasoning content handling.
it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
// Simulate the conversion logic
const dbMessage = {
- role: MessageRole.ASSISTANT,
content: 'The answer is 4.',
- reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
+ reasoningContent: 'Let me think: 2+2=4, basic arithmetic.',
+ role: MessageRole.ASSISTANT
};
-
const apiMessage: Record<string, unknown> = {
- role: dbMessage.role,
- content: dbMessage.content
+ content: dbMessage.content,
+ role: dbMessage.role
};
+
if (dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
const dbMessage = {
- role: MessageRole.ASSISTANT,
content: 'The answer is 4.',
- reasoningContent: 'internal thinking'
+ reasoningContent: 'internal thinking',
+ role: MessageRole.ASSISTANT
};
-
const excludeReasoningFromContext = true;
-
const apiMessage: Record<string, unknown> = {
- role: dbMessage.role,
- content: dbMessage.content
+ content: dbMessage.content,
+ role: dbMessage.role
};
+
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
it('handles messages with no reasoning', () => {
const dbMessage = {
- role: MessageRole.ASSISTANT,
content: 'No reasoning here.',
- reasoningContent: undefined
+ reasoningContent: undefined,
+ role: MessageRole.ASSISTANT
};
-
const apiMessage: Record<string, unknown> = {
- role: dbMessage.role,
- content: dbMessage.content
+ content: dbMessage.content,
+ role: dbMessage.role
};
+
if (dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
}
-import { describe, expect, it } from 'vitest';
import { redactValue } from '$lib/utils/redact';
+import { describe, expect, it } from 'vitest';
describe('redactValue', () => {
it('returns [redacted] by default', () => {
-import { describe, expect, it } from 'vitest';
import {
- getRequestUrl,
- getRequestMethod,
- getRequestBody,
- summarizeRequestBody,
+ extractJsonRpcMethods,
formatDiagnosticErrorMessage,
- extractJsonRpcMethods
+ getRequestBody,
+ getRequestMethod,
+ getRequestUrl,
+ summarizeRequestBody
} from '$lib/utils/request-helpers';
+import { describe, expect, it } from 'vitest';
describe('getRequestUrl', () => {
it('returns a plain string input as-is', () => {
it('returns url from a Request object', () => {
const req = new Request('https://example.com/mcp');
+
expect(getRequestUrl(req)).toBe('https://example.com/mcp');
});
});
it('falls back to Request.method', () => {
const req = new Request('https://example.com', { method: 'PUT' });
+
expect(getRequestMethod(req)).toBe('PUT');
});
it('returns blob kind with size', () => {
const blob = new Blob(['abc']);
+
expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 });
});
describe('extractJsonRpcMethods', () => {
it('extracts methods from a JSON-RPC array', () => {
const body = JSON.stringify([
- { jsonrpc: '2.0', id: 1, method: 'initialize' },
+ { id: 1, jsonrpc: '2.0', method: 'initialize' },
{ jsonrpc: '2.0', method: 'notifications/initialized' }
]);
+
expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']);
});
it('extracts method from a single JSON-RPC message', () => {
- const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
+ const body = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list' });
+
expect(extractJsonRpcMethods(body)).toEqual(['tools/list']);
});
-import { describe, expect, it } from 'vitest';
-import { sanitizeHeaders } from '$lib/utils/api-headers';
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
+import { sanitizeHeaders } from '$lib/utils/api-headers';
+import { describe, expect, it } from 'vitest';
describe('sanitizeHeaders', () => {
it('returns empty object for undefined input', () => {
});
it('passes through non-sensitive headers', () => {
- const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' });
+ const headers = new Headers({ accept: 'text/html', 'content-type': 'application/json' });
+
expect(sanitizeHeaders(headers)).toEqual({
- 'content-type': 'application/json',
- accept: 'text/html'
+ accept: 'text/html',
+ 'content-type': 'application/json'
});
});
it('redacts known sensitive headers', () => {
const headers = new Headers({
authorization: 'Bearer secret',
- 'x-api-key': 'key-123',
- 'content-type': 'application/json'
+ 'content-type': 'application/json',
+ 'x-api-key': 'key-123'
});
const result = sanitizeHeaders(headers);
+
expect(result.authorization).toBe('[redacted]');
expect(result['x-api-key']).toBe('[redacted]');
expect(result['content-type']).toBe('application/json');
it('partially redacts headers specified in partialRedactHeaders', () => {
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
const partial = new Map([['mcp-session-id', 5]]);
+
expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345');
});
it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => {
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
+
expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]');
});
it('redacts extra headers provided by the caller', () => {
const headers = new Headers({
- 'x-vendor-key': 'vendor-secret',
- 'content-type': 'application/json'
+ 'content-type': 'application/json',
+ 'x-vendor-key': 'vendor-secret'
});
const result = sanitizeHeaders(headers, ['x-vendor-key']);
+
expect(result['x-vendor-key']).toBe('[redacted]');
expect(result['content-type']).toBe('application/json');
});
it('handles case-insensitive extra header names', () => {
const headers = new Headers({ 'X-Custom-Token': 'token-value' });
const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']);
+
expect(result['x-custom-token']).toBe('[redacted]');
});
-import { describe, it, expect } from 'vitest';
-import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
+import { extractSearchQuery, extractSearchResults } from '$lib/utils/search-results';
+import { describe, expect, it } from 'vitest';
const SAMPLE = `Title: World Cup 2026 | Match schedule, fixtures, results & stadiums
URL: https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums
Something
# World Cup
...`;
-
const QUERY_ARGS = '{"query":"FIFA World Cup 2026 schedule"}';
describe('real-world Exa fixture', () => {
it('extracts every search result and preserves rich highlights', () => {
const results = extractSearchResults(SAMPLE);
+
expect(results.length).toBe(3);
expect(results[0].title).toContain('World Cup 2026 | Match schedule');
expect(results[0].url).toContain('fifa.com');
-import { describe, expect, it } from 'vitest';
import {
- extractSearchResults,
extractSearchQuery,
+ extractSearchResults,
faviconForUrl,
isWebSearchToolName
} from '$lib/utils/search-results';
+import { describe, expect, it } from 'vitest';
describe('extractSearchResults', () => {
it('parses the Exa fixture with multiple results', () => {
# FIFA World Cup Schedule
...`;
const results = extractSearchResults(fixture);
+
expect(results.length).toBe(3);
expect(results[0].title).toContain('World Cup 2026');
expect(results[0].url).toBe('https://www.fifa.com/articles/match-schedule');
Title: b
URL: not a url`;
const results = extractSearchResults(txt);
+
// Only middle one should pass (has title + url).
expect(results.length).toBe(1);
expect(results[0].url).toBe('https://x.com');
Highlights:
a highlight`;
const results = extractSearchResults(txt);
+
expect(results.length).toBe(1);
expect(results[0].title).toBe('only one');
expect(results[0].author).toBe('alice');
-import { describe, expect, it } from 'vitest';
import { SourceHistory } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('SourceHistory', () => {
it('coalesces pushes inside the group window into one undo step', () => {
const h = new SourceHistory(100, 800);
- h.push({ value: '', caret: 0 }, 1000);
- h.push({ value: 'a', caret: 1 }, 1200);
- h.push({ value: 'ab', caret: 2 }, 1500);
- expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
- expect(h.undo({ value: '', caret: 0 })).toBeNull();
+ h.push({ caret: 0, value: '' }, 1000);
+ h.push({ caret: 1, value: 'a' }, 1200);
+ h.push({ caret: 2, value: 'ab' }, 1500);
+
+ expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
+ expect(h.undo({ caret: 0, value: '' })).toBeNull();
});
it('starts a new group once the window has passed', () => {
const h = new SourceHistory(100, 800);
- h.push({ value: '', caret: 0 }, 1000);
- h.push({ value: 'abc', caret: 3 }, 2000);
- expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 });
- expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
+ h.push({ caret: 0, value: '' }, 1000);
+ h.push({ caret: 3, value: 'abc' }, 2000);
+
+ expect(h.undo({ caret: 6, value: 'abcdef' })).toEqual({ caret: 3, value: 'abc' });
+ expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
});
it('newGroup forces a separate entry even inside the window', () => {
const h = new SourceHistory(100, 800);
- h.push({ value: '', caret: 0 }, 1000);
- h.push({ value: 'abc', caret: 3 }, 1100, true);
- expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 });
- expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
+ h.push({ caret: 0, value: '' }, 1000);
+ h.push({ caret: 3, value: 'abc' }, 1100, true);
+
+ expect(h.undo({ caret: 4, value: 'abc\n' })).toEqual({ caret: 3, value: 'abc' });
+ expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
});
it('redo round-trips and a fresh push clears the redo stack', () => {
const h = new SourceHistory(100, 800);
- h.push({ value: '', caret: 0 }, 1000);
- const undone = h.undo({ value: 'abc', caret: 3 });
- expect(undone).toEqual({ value: '', caret: 0 });
- expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 });
+ h.push({ caret: 0, value: '' }, 1000);
- h.undo({ value: 'abc', caret: 3 });
- h.push({ value: '', caret: 0 }, 5000);
- expect(h.redo({ value: 'x', caret: 1 })).toBeNull();
+ const undone = h.undo({ caret: 3, value: 'abc' });
+
+ expect(undone).toEqual({ caret: 0, value: '' });
+ expect(h.redo({ caret: 0, value: '' })).toEqual({ caret: 3, value: 'abc' });
+
+ h.undo({ caret: 3, value: 'abc' });
+ h.push({ caret: 0, value: '' }, 5000);
+ expect(h.redo({ caret: 1, value: 'x' })).toBeNull();
});
it('starts a new group on the first edit after an undo', () => {
const h = new SourceHistory(100, 800);
- h.push({ value: '', caret: 0 }, 1000);
- h.undo({ value: 'abc', caret: 3 });
- h.push({ value: '', caret: 0 }, 1200);
- expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 });
+ h.push({ caret: 0, value: '' }, 1000);
+ h.undo({ caret: 3, value: 'abc' });
+
+ h.push({ caret: 0, value: '' }, 1200);
+ expect(h.undo({ caret: 1, value: 'x' })).toEqual({ caret: 0, value: '' });
});
it('evicts the oldest entry past the limit', () => {
const h = new SourceHistory(2, 800);
- h.push({ value: 'one', caret: 0 }, 1000);
- h.push({ value: 'two', caret: 0 }, 2000);
- h.push({ value: 'three', caret: 0 }, 3000);
- expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 });
- expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 });
- expect(h.undo({ value: 'two', caret: 0 })).toBeNull();
+ h.push({ caret: 0, value: 'one' }, 1000);
+ h.push({ caret: 0, value: 'two' }, 2000);
+ h.push({ caret: 0, value: 'three' }, 3000);
+
+ expect(h.undo({ caret: 0, value: 'cur' })).toEqual({ caret: 0, value: 'three' });
+ expect(h.undo({ caret: 0, value: 'three' })).toEqual({ caret: 0, value: 'two' });
+ expect(h.undo({ caret: 0, value: 'two' })).toBeNull();
});
});
-import { describe, expect, it } from 'vitest';
import { parseSseJsonStream } from '$lib/utils/sse';
+import { describe, expect, it } from 'vitest';
function makeSseResponse(events: string[]): Response {
const body = events.join('\n\n') + '\n\n';
+
return new Response(body, {
- status: 200,
- headers: { 'content-type': 'text/event-stream' }
+ headers: { 'content-type': 'text/event-stream' },
+ status: 200
});
}
it('yields parsed data for each record', async () => {
const response = makeSseResponse(['data: {"chunk": "a"}', 'data: {"chunk": "b"}']);
const collected: unknown[] = [];
+
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
'data: {"chunk": "after-done"}'
]);
const collected: unknown[] = [];
+
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
'data: {"chunk": "also-ok"}'
]);
const collected: unknown[] = [];
+
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const enc = new TextEncoder();
+
controller.enqueue(enc.encode(full.slice(0, full.length / 2)));
controller.enqueue(enc.encode(full.slice(full.length / 2)));
controller.close();
}
});
const response = new Response(stream, {
- status: 200,
- headers: { 'content-type': 'text/event-stream' }
+ headers: { 'content-type': 'text/event-stream' },
+ status: 200
});
const collected: unknown[] = [];
+
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
it('returns immediately if response has no body', async () => {
const response = new Response(null, { status: 200 });
const collected: unknown[] = [];
+
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
-import { describe, expect, it } from 'vitest';
import { ChatService } from '$lib/services/chat.service';
import type { ApiStreamSession } from '$lib/types';
+import { describe, expect, it } from 'vitest';
function makeSession(overrides: Partial<ApiStreamSession>): ApiStreamSession {
return {
+ completed_at: 0,
conversation_id: 'conv',
is_done: true,
- total_bytes: 0,
started_at: 0,
- completed_at: 0,
+ total_bytes: 0,
...overrides
};
}
it('returns the single session when it is running', () => {
const s = makeSession({ conversation_id: 'only', is_done: false, started_at: 42 });
+
expect(ChatService.selectActiveStream([s])).toBe(s);
});
it('returns null when the single session is finalized', () => {
const s = makeSession({ conversation_id: 'only', is_done: true, started_at: 42 });
+
expect(ChatService.selectActiveStream([s])).toBeNull();
});
it('prefers a still running session over a finalized one regardless of started_at', () => {
const finalized = makeSession({ conversation_id: 'old', is_done: true, started_at: 1000 });
const running = makeSession({ conversation_id: 'new', is_done: false, started_at: 10 });
+
expect(ChatService.selectActiveStream([finalized, running])?.conversation_id).toBe('new');
expect(ChatService.selectActiveStream([running, finalized])?.conversation_id).toBe('new');
});
const a = makeSession({ conversation_id: 'a', is_done: false, started_at: 100 });
const b = makeSession({ conversation_id: 'b', is_done: false, started_at: 200 });
const c = makeSession({ conversation_id: 'c', is_done: false, started_at: 150 });
+
expect(ChatService.selectActiveStream([a, b, c])?.conversation_id).toBe('b');
expect(ChatService.selectActiveStream([c, a, b])?.conversation_id).toBe('b');
});
const a = makeSession({ conversation_id: 'a', is_done: true, started_at: 10 });
const b = makeSession({ conversation_id: 'b', is_done: true, started_at: 30 });
const c = makeSession({ conversation_id: 'c', is_done: true, started_at: 20 });
+
expect(ChatService.selectActiveStream([a, b, c])).toBeNull();
});
// reduce visits left to right, the initial accumulator stays unless a strictly greater value appears
const a = makeSession({ conversation_id: 'first', is_done: false, started_at: 50 });
const b = makeSession({ conversation_id: 'second', is_done: false, started_at: 50 });
+
expect(ChatService.selectActiveStream([a, b])?.conversation_id).toBe('first');
});
const old2 = makeSession({ conversation_id: 'old2', is_done: true, started_at: 200 });
const freshFin = makeSession({ conversation_id: 'freshFin', is_done: true, started_at: 500 });
const running = makeSession({ conversation_id: 'running', is_done: false, started_at: 400 });
+
expect(ChatService.selectActiveStream([old1, old2, freshFin, running])?.conversation_id).toBe(
'running'
);
beforeAll(() => {
const store = new Map<string, string>();
const polyfill: Storage = {
- get length() {
- return store.size;
- },
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
+ get length() {
+ return store.size;
+ },
removeItem: (k) => {
store.delete(k);
},
store.set(k, String(v));
}
};
+
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
});
-import { ChatService } from '$lib/services/chat.service';
import { STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX } from '$lib/constants';
+import { ChatService } from '$lib/services/chat.service';
describe('ChatService stream resume', () => {
beforeEach(() => {
it('saves and reads back the byte count', () => {
ChatService.saveStreamState('conv-a', 4242);
const got = ChatService.getStreamState('conv-a');
+
expect(got).not.toBeNull();
expect(got!.bytesReceived).toBe(4242);
expect(typeof got!.updatedAt).toBe('number');
ChatService.saveStreamState('conv-a', 100);
ChatService.saveStreamState('conv-a', 200);
const got = ChatService.getStreamState('conv-a');
+
expect(got!.bytesReceived).toBe(200);
});
-import { describe, expect, it } from 'vitest';
import { tryParseToolResultObject } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
describe('tryParseToolResultObject', () => {
it('returns null when no result is provided', () => {
it('returns the parsed object when the result is JSON', () => {
expect(tryParseToolResultObject('{"result":"ok","bytes":42}')).toEqual({
- result: 'ok',
- bytes: 42
+ bytes: 42,
+ result: 'ok'
});
});
-import { describe, expect, it } from 'vitest';
-import { AgenticSectionType, BuiltInTool } from '$lib/enums';
-import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
-import { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
+import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
+import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
+import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
+import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
+import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
+import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
import {
parseWriteFileMeta,
type WriteFileMeta
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
-import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
-import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
-import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
-import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
-import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
-import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
+import { AgenticSectionType, BuiltInTool } from '$lib/enums';
+import type { AgenticSection } from '$lib/utils';
+import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
function makeSection(
overrides: Partial<AgenticSection> = {},
toolName = BuiltInTool.READ_FILE
): AgenticSection {
return {
- type: AgenticSectionType.TOOL_CALL,
content: '',
- toolName,
toolArgs: JSON.stringify({ path: '/foo.txt' }),
+ toolName,
toolResult: undefined,
+ type: AgenticSectionType.TOOL_CALL,
...overrides
};
}
it('round-trips through the parser', () => {
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
+
expect(info?.path).toBe('/Users/al/Documents');
expect(info?.display).toBe('~/Documents');
});
parseCwdMessage(
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
)
- ).toEqual({ path: '/a/b', display: '~/b' });
+ ).toEqual({ display: '~/b', path: '/a/b' });
});
it('parses the cleared marker', () => {
- expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
+ expect(parseCwdMessage('Working directory cleared')).toEqual({ display: '', path: null });
});
it('returns null for non-cwd content', () => {
describe('parseToolArgs (shared)', () => {
it('returns null when the section has no toolArgs', () => {
const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
+
expect(result).toBeNull();
});
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.WRITE_FILE)
);
+
expect(result).toBeNull();
});
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path": "/foo.tx' })
);
+
expect(result).toBeNull();
});
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path":"/foo.txt"}' })
);
+
expect(result).toEqual({ path: '/foo.txt' });
});
makeSection({ toolArgs: '{"path": "/foo.tx' }),
{ partial: true }
);
+
expect(result).toEqual({ path: '/foo.tx' });
});
});
it('returns null for sections with a different tool name', () => {
expect(
parseWriteFileMeta(
- makeSection({ toolName: BuiltInTool.READ_FILE, toolArgs: '{"path":"/x","content":"y"}' })
+ makeSection({ toolArgs: '{"path":"/x","content":"y"}', toolName: BuiltInTool.READ_FILE })
)
).toBeNull();
});
it('returns null when args have no path-like field', () => {
expect(
parseWriteFileMeta(
- makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"content":"x"}' })
+ makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.WRITE_FILE })
)
).toBeNull();
});
it('accepts partial args (renders incrementally as content streams in)', () => {
const meta = parseWriteFileMeta(
- makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"path":"/foo.t' })
+ makeSection({ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.WRITE_FILE })
);
+
expect(meta?.filePath).toBe('/foo.t');
});
const meta = parseWriteFileMeta(
makeSection(
{
- toolName: BuiltInTool.WRITE_FILE,
toolArgs: '{"path":"/foo.ts","content":"x"}',
+ toolName: BuiltInTool.WRITE_FILE,
toolResult: '{"result":"wrote","bytes":42}'
},
BuiltInTool.WRITE_FILE
)
);
+
expect(meta).toMatchObject<Partial<WriteFileMeta>>({
+ bytesWritten: 42,
+ content: 'x',
filePath: '/foo.ts',
language: expect.any(String),
- content: 'x',
- bytesWritten: 42,
resultMessage: 'wrote'
});
});
it('surfaces errorMessage from the result blob', () => {
const meta = parseWriteFileMeta(
makeSection({
- toolName: BuiltInTool.WRITE_FILE,
toolArgs: '{"path":"/foo","content":"x"}',
+ toolName: BuiltInTool.WRITE_FILE,
toolResult: '{"error":"permission denied"}'
})
);
+
expect(meta?.errorMessage).toBe('permission denied');
});
});
it('parses edits array and applies editsApplied from the result', () => {
const section = makeSection(
{
- toolName: BuiltInTool.EDIT_FILE,
toolArgs:
'{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
+ toolName: BuiltInTool.EDIT_FILE,
toolResult: '{"result":"ok","edits_applied":2}'
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
+
expect(meta?.edits).toEqual([
- { oldText: 'a', newText: 'b' },
- { oldText: 'c', newText: 'd' }
+ { newText: 'b', oldText: 'a' },
+ { newText: 'd', oldText: 'c' }
]);
expect(meta?.editsApplied).toBe(2);
expect(meta?.resultMessage).toBe('ok');
it('drops edits with empty old_text', () => {
const section = makeSection(
{
- toolName: BuiltInTool.EDIT_FILE,
- toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}'
+ toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}',
+ toolName: BuiltInTool.EDIT_FILE
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
+
// First entry is dropped (empty old_text). Second is kept
// (empty new_text is fine - it's the "delete" case).
- expect(meta?.edits).toEqual([{ oldText: 'a', newText: '' }]);
+ expect(meta?.edits).toEqual([{ newText: '', oldText: 'a' }]);
});
it('errorMessage wins over result message', () => {
const section = makeSection(
{
- toolName: BuiltInTool.EDIT_FILE,
toolArgs: '{"path":"/foo"}',
+ toolName: BuiltInTool.EDIT_FILE,
toolResult: '{"error":"bad path","result":"ok"}'
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
+
expect(meta?.errorMessage).toBe('bad path');
expect(meta?.resultMessage).toBeUndefined();
});
const meta = parseReadFileMeta(
makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.READ_FILE)
);
+
expect(meta?.fileName).toBe('foo.txt');
expect(meta?.lineRange).toBeNull();
});
BuiltInTool.READ_FILE
)
);
- expect(meta?.lineRange).toEqual({ start: 10, end: 20 });
+
+ expect(meta?.lineRange).toEqual({ end: 20, start: 10 });
});
it('parses start_line + line_count into a range', () => {
BuiltInTool.READ_FILE
)
);
- expect(meta?.lineRange).toEqual({ start: 10, end: 14 });
+
+ expect(meta?.lineRange).toEqual({ end: 14, start: 10 });
});
it('returns null when args cannot be parsed', () => {
it('returns null when path or pattern is missing', () => {
expect(
parseGrepSearchMeta(
- makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"pattern":"foo"}' })
+ makeSection({ toolArgs: '{"pattern":"foo"}', toolName: BuiltInTool.GREP_SEARCH })
)
).toBeNull();
expect(
parseGrepSearchMeta(
- makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"path":"/x"}' })
+ makeSection({ toolArgs: '{"path":"/x"}', toolName: BuiltInTool.GREP_SEARCH })
)
).toBeNull();
});
const meta = parseGrepSearchMeta(
makeSection(
{
- toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo"}',
+ toolName: BuiltInTool.GREP_SEARCH,
toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
},
BuiltInTool.GREP_SEARCH
)
);
+
expect(meta?.matches).toHaveLength(2);
- expect(meta?.matches[0]).toEqual({ file: 'a.ts', content: 'hello' });
+ expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts' });
});
it('falls back to raw-text parsing when result is not JSON', () => {
const meta = parseGrepSearchMeta(
makeSection(
{
- toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo"}',
+ toolName: BuiltInTool.GREP_SEARCH,
toolResult: 'a.ts:hello\nb.ts:world'
},
BuiltInTool.GREP_SEARCH
)
);
+
expect(meta?.matches).toHaveLength(2);
});
const meta = parseGrepSearchMeta(
makeSection(
{
- toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
+ toolName: BuiltInTool.GREP_SEARCH,
toolResult: 'a.ts:12:hello'
},
BuiltInTool.GREP_SEARCH
)
);
- expect(meta?.matches[0]).toEqual({ file: 'a.ts', line: 12, content: 'hello' });
+
+ expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts', line: 12 });
expect(meta?.showLineNumbers).toBe(true);
});
});
const meta = parseFileGlobSearchMeta(
makeSection(
{
- toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
+ toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolResult: 'a.ts\nb.ts'
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
+
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
});
const meta = parseFileGlobSearchMeta(
makeSection(
{
- toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
+ toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
+
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
});
const meta = parseFileGlobSearchMeta(
makeSection(
{
- toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
+ toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolResult: JSON.stringify({ error: 'permission denied' })
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
+
expect(meta?.errorMessage).toBe('permission denied');
});
});
describe('parseRunJavascriptMeta', () => {
it('returns null when code is missing', () => {
expect(
- parseRunJavascriptMeta(makeSection({ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{}' }))
+ parseRunJavascriptMeta(makeSection({ toolArgs: '{}', toolName: BuiltInTool.RUN_JAVASCRIPT }))
).toBeNull();
});
it('reads code and timeout', () => {
const meta = parseRunJavascriptMeta(
makeSection(
- { toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{"code":"Math.PI","timeout_ms":5000}' },
+ { toolArgs: '{"code":"Math.PI","timeout_ms":5000}', toolName: BuiltInTool.RUN_JAVASCRIPT },
BuiltInTool.RUN_JAVASCRIPT
)
);
+
expect(meta?.code).toBe('Math.PI');
expect(meta?.timeoutMs).toBe(5000);
});
const meta = parseRunJavascriptMeta(
makeSection(
{
- toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"throw new Error()"}',
+ toolName: BuiltInTool.RUN_JAVASCRIPT,
toolResult: JSON.stringify({ error: 'undefined is not a function' })
},
BuiltInTool.RUN_JAVASCRIPT
)
);
+
expect(meta?.errorMessage).toBe('undefined is not a function');
});
const meta = parseRunJavascriptMeta(
makeSection(
{
- toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"[1,2,3]"}',
+ toolName: BuiltInTool.RUN_JAVASCRIPT,
toolResult: '[1,2,3]'
},
BuiltInTool.RUN_JAVASCRIPT
)
);
+
expect(meta?.errorMessage).toBeUndefined();
});
const meta = parseRunJavascriptMeta(
makeSection(
{
- toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"foo"}',
+ toolName: BuiltInTool.RUN_JAVASCRIPT,
toolResult: 'Error: undefined is not a function\n at <anonymous>:1:1'
},
BuiltInTool.RUN_JAVASCRIPT
)
);
+
expect(meta?.errorMessage).toBe('undefined is not a function');
});
});
it('reads command from the args', () => {
const meta = parseExecShellCommandMeta(
makeSection(
- { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"command":"ls -la"}' },
+ { toolArgs: '{"command":"ls -la"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
BuiltInTool.EXEC_SHELL_COMMAND
)
);
+
expect(meta?.command).toBe('ls -la');
});
expect(
parseExecShellCommandMeta(
makeSection(
- { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cmd":"ls"}' },
+ { toolArgs: '{"cmd":"ls"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
BuiltInTool.EXEC_SHELL_COMMAND
)
)?.command
expect(
parseExecShellCommandMeta(
makeSection(
- { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"shell_command":"ls"}' },
+ { toolArgs: '{"shell_command":"ls"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
BuiltInTool.EXEC_SHELL_COMMAND
)
)?.command
expect(
parseExecShellCommandMeta(
makeSection(
- { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cwd":"/x"}' },
+ { toolArgs: '{"cwd":"/x"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
BuiltInTool.EXEC_SHELL_COMMAND
)
)
-import { describe, it, expect } from 'vitest';
+import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
import {
- extractTemplateVariables,
expandTemplate,
+ extractTemplateVariables,
isTemplateComplete,
normalizeResourceUri
} from '../../src/lib/utils/uri-template';
-import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
+import { describe, expect, it } from 'vitest';
describe('extractTemplateVariables', () => {
it('extracts simple variables', () => {
const vars = extractTemplateVariables('file:///{path}');
+
expect(vars).toEqual([{ name: 'path', operator: '' }]);
});
it('extracts multiple variables', () => {
const vars = extractTemplateVariables('db://{schema}/{table}');
+
expect(vars).toEqual([
{ name: 'schema', operator: '' },
{ name: 'table', operator: '' }
it('extracts variables with operators', () => {
const vars = extractTemplateVariables('http://example.com{+path}');
+
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]);
});
it('extracts comma-separated variable lists', () => {
const vars = extractTemplateVariables('{x,y,z}');
+
expect(vars).toEqual([
{ name: 'x', operator: '' },
{ name: 'y', operator: '' },
it('deduplicates variable names', () => {
const vars = extractTemplateVariables('{name}/{name}');
+
expect(vars).toEqual([{ name: 'name', operator: '' }]);
});
it('handles fragment expansion', () => {
const vars = extractTemplateVariables('http://example.com/page{#section}');
+
expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]);
});
it('handles path segment expansion', () => {
const vars = extractTemplateVariables('http://example.com{/path}');
+
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]);
});
it('returns empty array for template without variables', () => {
const vars = extractTemplateVariables('http://example.com/static');
+
expect(vars).toEqual([]);
});
it('strips explode modifier', () => {
const vars = extractTemplateVariables('{list*}');
+
expect(vars).toEqual([{ name: 'list', operator: '' }]);
});
it('strips prefix modifier', () => {
const vars = extractTemplateVariables('{value:5}');
+
expect(vars).toEqual([{ name: 'value', operator: '' }]);
});
});
describe('expandTemplate', () => {
it('expands simple variable', () => {
const result = expandTemplate('file:///{path}', { path: 'src/main.rs' });
+
expect(result).toBe('file:///src%2Fmain.rs');
});
it('expands reserved variable (no encoding)', () => {
const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' });
+
expect(result).toBe('file:///src/main.rs');
});
schema: 'public',
table: 'users'
});
+
expect(result).toBe('db://public/users');
});
it('leaves empty for missing variables', () => {
const result = expandTemplate('{missing}', {});
+
expect(result).toBe('');
});
const result = expandTemplate('http://example.com/page{#section}', {
section: 'intro'
});
+
expect(result).toBe('http://example.com/page#intro');
});
it('expands path segments', () => {
const result = expandTemplate('http://example.com{/path}', { path: 'docs' });
+
expect(result).toBe('http://example.com/docs');
});
it('expands query parameters', () => {
const result = expandTemplate('http://example.com{?q}', { q: 'search term' });
+
expect(result).toBe('http://example.com?q=search%20term');
});
q: 'search term',
sort: 'descending'
});
+
expect(result).toBe('http://example.com?q=search%20term&sort=descending');
});
it('keeps static parts unchanged', () => {
const result = expandTemplate('http://example.com/static', {});
+
expect(result).toBe('http://example.com/static');
});
});
-import { describe, expect, it } from 'vitest';
+import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
import {
- splitPathQuery,
buildCaseInsensitiveGlob,
buildGlobSearchArgs,
- rankEntries,
+ highlightMatch,
joinPath,
- highlightMatch
+ rankEntries,
+ splitPathQuery
} from '$lib/utils';
-import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
+import { describe, expect, it } from 'vitest';
describe('splitPathQuery', () => {
it('treats a plain query as a home-relative glob (not navigation)', () => {
});
it('navigates the root for `/`', () => {
- expect(splitPathQuery('/')).toEqual({ parent: '/', last: '' });
+ expect(splitPathQuery('/')).toEqual({ last: '', parent: '/' });
});
it('navigates home for `~`', () => {
- expect(splitPathQuery('~')).toEqual({ parent: '~', last: '' });
+ expect(splitPathQuery('~')).toEqual({ last: '', parent: '~' });
});
it('splits an absolute path into parent and last segment', () => {
- expect(splitPathQuery('/Users/al/proj')).toEqual({ parent: '/Users/al', last: 'proj' });
+ expect(splitPathQuery('/Users/al/proj')).toEqual({ last: 'proj', parent: '/Users/al' });
});
it('navigates a Windows drive path written with backslashes', () => {
expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
- parent: 'C:/repos',
- last: 'llama.cpp'
+ last: 'llama.cpp',
+ parent: 'C:/repos'
});
});
it('navigates a Windows drive path written with forward slashes', () => {
- expect(splitPathQuery('D:/repos')).toEqual({ parent: 'D:/', last: 'repos' });
+ expect(splitPathQuery('D:/repos')).toEqual({ last: 'repos', parent: 'D:/' });
});
it('treats a bare drive as its root', () => {
- expect(splitPathQuery('D:')).toEqual({ parent: 'D:/', last: '' });
- expect(splitPathQuery('D:\\')).toEqual({ parent: 'D:/', last: '' });
+ expect(splitPathQuery('D:')).toEqual({ last: '', parent: 'D:/' });
+ expect(splitPathQuery('D:\\')).toEqual({ last: '', parent: 'D:/' });
});
it('navigates a UNC share', () => {
expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
- parent: '//host/share/',
- last: 'proj'
+ last: 'proj',
+ parent: '//host/share/'
});
});
it('keeps a backslash as a POSIX filename character', () => {
- expect(splitPathQuery('/tmp/a\\b')).toEqual({ parent: '/tmp', last: 'a\\b' });
+ expect(splitPathQuery('/tmp/a\\b')).toEqual({ last: 'a\\b', parent: '/tmp' });
});
it('splits a home-relative path into parent and last segment', () => {
- expect(splitPathQuery('~/Documents')).toEqual({ parent: '~', last: 'Documents' });
+ expect(splitPathQuery('~/Documents')).toEqual({ last: 'Documents', parent: '~' });
});
it('strips trailing slashes before splitting', () => {
- expect(splitPathQuery('/Users/al/')).toEqual({ parent: '/Users', last: 'al' });
+ expect(splitPathQuery('/Users/al/')).toEqual({ last: 'al', parent: '/Users' });
});
it('handles a single-segment absolute path', () => {
- expect(splitPathQuery('/opt')).toEqual({ parent: '/', last: 'opt' });
+ expect(splitPathQuery('/opt')).toEqual({ last: 'opt', parent: '/' });
});
});
it('ranks exact basename match first', () => {
const ranked = rankEntries(entries, 'read');
+
expect(ranked[0].path).toBe('/h/read');
});
it('breaks ties by shorter path, then alphabetically', () => {
const ranked = rankEntries(entries, 'read');
+
expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
});
it('does not mutate the input', () => {
const snapshot = [...entries];
+
rankEntries(entries, 'read');
expect(entries).toEqual(snapshot);
});
describe('highlightMatch', () => {
it('returns a single non-matching segment when query is empty', () => {
- expect(highlightMatch('abc', '')).toEqual([{ text: 'abc', match: false }]);
+ expect(highlightMatch('abc', '')).toEqual([{ match: false, text: 'abc' }]);
});
it('marks every case-insensitive occurrence of the query', () => {
expect(highlightMatch('aXa', 'ax')).toEqual([
- { text: 'aX', match: true },
- { text: 'a', match: false }
+ { match: true, text: 'aX' },
+ { match: false, text: 'a' }
]);
});
it('returns non-matching text when the query is absent', () => {
- expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
+ expect(highlightMatch('abc', 'z')).toEqual([{ match: false, text: 'abc' }]);
});
});
it('glob-matches home-relative within the scope path', () => {
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
+
expect(args.path).toBe('/home');
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
expect(args.maxDepth).toBe(DEPTH);
it('navigates home for a `~` path query', () => {
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
+
expect(args.path).toBe('~');
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
it('lists the scope root when a path query has no last segment', () => {
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
+
expect(args.path).toBe('~');
expect(args.include).toBe(GLOB_WILDCARD);
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
it('navigates an absolute path under its root', () => {
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
+
expect(args.path).toBe('/usr/local');
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
-import tailwindcss from '@tailwindcss/vite';
+import { buildInfoPlugin } from './scripts/vite-plugin-build-info';
+import { nerdamerPlugin } from './scripts/vite-plugin-nerdamer';
+import { relativizeBasePlugin } from './scripts/vite-plugin-relativize-base';
+import { splashScreenPlugin } from './scripts/vite-plugin-splash-screen';
+import { SVELTEKIT_PWA_OPTIONS } from './src/lib/constants/pwa';
+import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
+import { playwright } from '@vitest/browser-playwright';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
-
import { defineConfig, searchForWorkspaceRoot } from 'vite';
-import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
-import { splashScreenPlugin } from './scripts/vite-plugin-splash-screen';
-import { buildInfoPlugin } from './scripts/vite-plugin-build-info';
-import { relativizeBasePlugin } from './scripts/vite-plugin-relativize-base';
-import { nerdamerPlugin } from './scripts/vite-plugin-nerdamer';
-import { playwright } from '@vitest/browser-playwright';
-import { SVELTEKIT_PWA_OPTIONS } from './src/lib/constants/pwa';
const __dirname = dirname(fileURLToPath(import.meta.url));
-
const SERVER_ORIGIN = import.meta.env?.VITE_PUBLIC_SERVER_ORIGIN || 'http://localhost:8080';
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const browserBaseConfig: any = {
enabled: true,
+ instances: [{ browser: 'chromium' }],
provider: playwright({
launchOptions: {
args: ['--no-sandbox']
}
- }),
- instances: [{ browser: 'chromium' }]
+ })
};
export default defineConfig({
- resolve: {
- alias: {
- 'katex-fonts': resolve('node_modules/katex/dist/fonts')
- }
- },
-
build: {
assetsInlineLimit: 32000,
chunkSizeWarningLimit: 3072,
relativizeBasePlugin()
],
+ resolve: {
+ alias: {
+ 'katex-fonts': resolve('node_modules/katex/dist/fonts')
+ }
+ },
+
+ server: {
+ fs: {
+ allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
+ },
+ headers: {
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ 'Cross-Origin-Opener-Policy': 'same-origin'
+ },
+ proxy: {
+ '/cors-proxy': SERVER_ORIGIN,
+ '/models': SERVER_ORIGIN,
+ '/props': SERVER_ORIGIN,
+ '/slots': SERVER_ORIGIN,
+ '/tools': SERVER_ORIGIN,
+ '/v1': SERVER_ORIGIN
+ }
+ },
+
test: {
projects: [
{
extends: './vite.config.ts',
test: {
- name: 'client',
browser: browserBaseConfig,
include: ['tests/client/**/*.svelte.{test,spec}.{js,ts}'],
+ name: 'client',
setupFiles: ['./vitest-setup-client.ts']
}
},
{
extends: './vite.config.ts',
test: {
- name: 'unit',
environment: 'node',
- include: ['tests/unit/**/*.{test,spec}.{js,ts}']
+ include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
+ name: 'unit'
}
},
{
extends: './vite.config.ts',
- test: {
- name: 'ui',
- browser: { ...browserBaseConfig, instances: [{ browser: 'chromium', headless: true }] }
- },
plugins: [
storybookTest({
storybookScript: 'pnpm run storybook --no-open'
})
- ]
+ ],
+ test: {
+ browser: { ...browserBaseConfig, instances: [{ browser: 'chromium', headless: true }] },
+ name: 'ui'
+ }
}
]
- },
-
- server: {
- proxy: {
- '/v1': SERVER_ORIGIN,
- '/props': SERVER_ORIGIN,
- '/models': SERVER_ORIGIN,
- '/tools': SERVER_ORIGIN,
- '/slots': SERVER_ORIGIN,
- '/cors-proxy': SERVER_ORIGIN
- },
- headers: {
- 'Cross-Origin-Embedder-Policy': 'require-corp',
- 'Cross-Origin-Opener-Policy': 'same-origin'
- },
- fs: {
- allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
- }
}
});
if (url.includes('/server')) {
return new Response(
JSON.stringify({
- mode: 'router',
- version: 'test',
+ git_branch: 'test',
git_commit: 'test',
- git_branch: 'test'
+ mode: 'router',
+ version: 'test'
}),
- { status: 200, headers: { 'Content-Type': 'application/json' } }
+ { headers: { 'Content-Type': 'application/json' }, status: 200 }
);
}
if (/\/v1\/models|\/models\b/.test(url)) {
return new Response(
JSON.stringify({
- object: 'list',
data: [
{
+ created: 0,
id: 'test-model.gguf',
+ in_cache: false,
+ meta: {},
object: 'model',
owned_by: 'llamacpp',
- created: 0,
- in_cache: false,
path: 'models/test-model.gguf',
- status: { value: 'unloaded' },
- meta: {}
+ status: { value: 'unloaded' }
}
],
models: [
{
+ details: {},
model: 'test-model.gguf',
- name: 'Test Model',
- details: {}
+ name: 'Test Model'
}
- ]
+ ],
+ object: 'list'
}),
- { status: 200, headers: { 'Content-Type': 'application/json' } }
+ { headers: { 'Content-Type': 'application/json' }, status: 200 }
);
}
JSON.stringify({
default_generation_settings: { n_ctx: 2048 }
}),
- { status: 200, headers: { 'Content-Type': 'application/json' } }
+ { headers: { 'Content-Type': 'application/json' }, status: 200 }
);
}
// Mock /tools endpoint (used for built-in tools list)
if (url.includes('/tools')) {
return new Response(JSON.stringify([]), {
- status: 200,
- headers: { 'Content-Type': 'application/json' }
+ headers: { 'Content-Type': 'application/json' },
+ status: 200
});
}