mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-31 17:17:44 +02:00
ui: Linting & Formatting scripts (#26819)
This commit is contained in:
+50
-14
@@ -1,14 +1,15 @@
|
||||
// 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));
|
||||
|
||||
@@ -21,32 +22,67 @@ export default ts.config(
|
||||
...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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+232
@@ -39,6 +39,8 @@
|
||||
"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",
|
||||
@@ -9281,6 +9283,226 @@
|
||||
"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",
|
||||
@@ -13196,6 +13418,16 @@
|
||||
"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",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"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",
|
||||
@@ -36,6 +36,7 @@
|
||||
"@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",
|
||||
@@ -57,6 +58,8 @@
|
||||
"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",
|
||||
@@ -99,8 +102,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
@@ -10,18 +10,18 @@ export default defineConfig({
|
||||
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']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
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,
|
||||
@@ -5,14 +13,6 @@ import {
|
||||
} 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
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: PWA_ASSET_GENERATOR.LINK_PRESET
|
||||
},
|
||||
images: ['static/favicon.svg'],
|
||||
preset: combinePresetAndAppleSplashScreens(
|
||||
{
|
||||
...minimal2023Preset,
|
||||
@@ -32,37 +33,37 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
{
|
||||
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']
|
||||
)
|
||||
});
|
||||
|
||||
@@ -4,12 +4,10 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
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 {
|
||||
@@ -39,8 +37,8 @@ export function colorizeFaviconSvg(
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,33 +52,40 @@ export function padFaviconSvg(svg: string, padding: number): string {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -93,14 +98,15 @@ export function writeThemeFavicons(
|
||||
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));
|
||||
|
||||
@@ -13,31 +13,28 @@
|
||||
* 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'];
|
||||
|
||||
@@ -47,10 +44,13 @@ async function makeCircle(targetFilename) {
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
console.log(`⏭️ ${sourceIcon} not found, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.log(`⏭️ ${targetFilename} not found, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,16 +58,18 @@ async function makeCircle(targetFilename) {
|
||||
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;
|
||||
@@ -77,8 +79,9 @@ async function makeCircle(targetFilename) {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -87,28 +90,26 @@ async function makeCircle(targetFilename) {
|
||||
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();
|
||||
@@ -130,6 +131,7 @@ async function main() {
|
||||
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'})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -15,27 +15,29 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
|
||||
*/
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fileURLToPath } from 'url';
|
||||
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;
|
||||
@@ -21,29 +20,32 @@ export function nerdamerPlugin(): Plugin {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -11,11 +11,15 @@ function rewrite(path: string, pairs: [string, string][]): void {
|
||||
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');
|
||||
}
|
||||
@@ -32,12 +36,12 @@ function rewrite(path: string, pairs: [string, string][]): void {
|
||||
*/
|
||||
export function relativizeBasePlugin(): Plugin {
|
||||
return {
|
||||
name: 'llamacpp:relativize-base',
|
||||
apply: 'build',
|
||||
closeBundle() {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (processed) return;
|
||||
|
||||
processed = true;
|
||||
|
||||
const outDir = resolve(OUTPUT_DIR);
|
||||
@@ -56,6 +60,7 @@ export function relativizeBasePlugin(): Plugin {
|
||||
console.error('Failed to relativize base refs:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
name: 'llamacpp:relativize-base'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { NEWLINE, TAB } from '../src/lib/constants/code';
|
||||
import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa';
|
||||
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';
|
||||
import { TAB, NEWLINE } 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';
|
||||
|
||||
let processed = false;
|
||||
|
||||
@@ -16,23 +16,26 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
|
||||
*/
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -42,20 +45,23 @@ export function generateSplashScreenLinks(outDir: string): string[] {
|
||||
|
||||
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}`;
|
||||
|
||||
@@ -73,16 +79,17 @@ export function generateSplashScreenLinks(outDir: string): string[] {
|
||||
|
||||
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');
|
||||
@@ -91,9 +98,11 @@ export function splashScreenPlugin(): Plugin {
|
||||
// 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>'
|
||||
@@ -110,6 +119,7 @@ export function splashScreenPlugin(): Plugin {
|
||||
console.error('Failed to process build output:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
name: 'llamacpp:splash-screen'
|
||||
};
|
||||
}
|
||||
|
||||
Vendored
+14
-17
@@ -3,9 +3,8 @@
|
||||
|
||||
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,
|
||||
@@ -13,59 +12,57 @@ import type {
|
||||
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 {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
@@ -20,18 +20,18 @@
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import ActionIcon from './ActionIcon.svelte';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
export let ariaLabel: string = 'Copy to clipboard';
|
||||
export let canCopy: boolean = true;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { modalities, class: className = '' }: Props = $props();
|
||||
let { class: className = '', modalities }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each modalities as modality (modality)}
|
||||
|
||||
+7
-7
@@ -28,18 +28,18 @@
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -48,7 +48,7 @@
|
||||
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();
|
||||
|
||||
+9
-9
@@ -2,8 +2,8 @@
|
||||
import {
|
||||
ChatAttachmentsListItemMcpPrompt,
|
||||
ChatAttachmentsListItemMcpResource,
|
||||
ChatAttachmentsListItemThumbnailImage,
|
||||
ChatAttachmentsListItemThumbnailFile
|
||||
ChatAttachmentsListItemThumbnailFile,
|
||||
ChatAttachmentsListItemThumbnailImage
|
||||
} from '$lib/components/app';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type {
|
||||
@@ -49,10 +49,10 @@
|
||||
return {
|
||||
id,
|
||||
resource: {
|
||||
uri: extra.uri,
|
||||
name: extra.name,
|
||||
serverName: extra.serverName,
|
||||
title: extra.name,
|
||||
serverName: extra.serverName
|
||||
uri: extra.uri
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -64,12 +64,12 @@
|
||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||
: item.uploadedFile?.mcpPrompt
|
||||
? {
|
||||
type: AttachmentType.MCP_PROMPT as const,
|
||||
name: item.name,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments,
|
||||
content: item.textContent ?? '',
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments
|
||||
name: item.name,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
type: AttachmentType.MCP_PROMPT as const
|
||||
}
|
||||
: null}
|
||||
{#if mcpPrompt}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
|
||||
+6
-5
@@ -1,11 +1,11 @@
|
||||
<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;
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
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';
|
||||
|
||||
+7
-7
@@ -1,17 +1,17 @@
|
||||
<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;
|
||||
@@ -31,9 +31,9 @@
|
||||
attachment,
|
||||
class: className = '',
|
||||
id,
|
||||
name,
|
||||
onclick,
|
||||
onRemove,
|
||||
name,
|
||||
readonly = false,
|
||||
size,
|
||||
textContent,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -20,9 +20,9 @@
|
||||
height = 'h-16',
|
||||
id,
|
||||
imageClass = '',
|
||||
name,
|
||||
onclick,
|
||||
onRemove,
|
||||
name,
|
||||
preview,
|
||||
readonly = false,
|
||||
width = 'w-auto'
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
getAttachmentDisplayItems,
|
||||
getLanguageFromFilename,
|
||||
isAudioFile,
|
||||
isVideoFile,
|
||||
isImageFile,
|
||||
isMcpPrompt,
|
||||
isMcpResource,
|
||||
isPdfFile,
|
||||
isTextFile
|
||||
isTextFile,
|
||||
isVideoFile
|
||||
} from '$lib/utils';
|
||||
|
||||
interface PreviewItem {
|
||||
@@ -42,21 +42,21 @@
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
)
|
||||
@@ -88,10 +88,11 @@
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
|
||||
+14
-14
@@ -1,12 +1,12 @@
|
||||
<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;
|
||||
@@ -25,19 +25,19 @@
|
||||
}
|
||||
|
||||
let {
|
||||
activeModelId,
|
||||
audioSrc,
|
||||
currentItem,
|
||||
isImage,
|
||||
isAudio,
|
||||
isVideo,
|
||||
isPdf,
|
||||
isText,
|
||||
displayPreview,
|
||||
displayTextContent,
|
||||
audioSrc,
|
||||
videoSrc,
|
||||
language,
|
||||
hasVisionModality,
|
||||
activeModelId
|
||||
isAudio,
|
||||
isImage,
|
||||
isPdf,
|
||||
isText,
|
||||
isVideo,
|
||||
language,
|
||||
videoSrc
|
||||
}: Props = $props();
|
||||
|
||||
let IconComponent = $derived(
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
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">
|
||||
|
||||
+10
-7
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
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 { 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 { getLanguageFromFilename } from '$lib/utils';
|
||||
import { convertPDFToImage } from '$lib/utils/browser-only';
|
||||
import { PdfViewMode } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
currentItem: ChatAttachmentDisplayItem | null;
|
||||
@@ -17,7 +17,7 @@
|
||||
activeModelId?: string;
|
||||
}
|
||||
|
||||
let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props =
|
||||
let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: Props =
|
||||
$props();
|
||||
|
||||
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
|
||||
@@ -47,6 +47,7 @@
|
||||
currentItem.attachment.images.length > 0
|
||||
) {
|
||||
pdfImages = currentItem.attachment.images;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,10 +56,12 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
let { onPrev, onNext, show }: Props = $props();
|
||||
let { onNext, onPrev, show }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
@@ -18,13 +18,15 @@
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
@@ -12,10 +13,10 @@
|
||||
} 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,
|
||||
@@ -24,20 +25,20 @@
|
||||
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,
|
||||
@@ -56,7 +57,6 @@
|
||||
parseClipboardContent,
|
||||
uuid
|
||||
} from '$lib/utils';
|
||||
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
@@ -96,12 +96,6 @@
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'Type a message...',
|
||||
showMcpPromptButton = false,
|
||||
showAddButton = true,
|
||||
showModelSelector = true,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable(''),
|
||||
onAttachmentRemove,
|
||||
onFilesAdd,
|
||||
onStop,
|
||||
@@ -109,7 +103,13 @@
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onUploadedFilesChange,
|
||||
onValueChange
|
||||
onValueChange,
|
||||
placeholder = 'Type a message...',
|
||||
showAddButton = true,
|
||||
showMcpPromptButton = false,
|
||||
showModelSelector = true,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// Component References
|
||||
@@ -146,32 +146,35 @@
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
focusInput: refocusInput,
|
||||
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(),
|
||||
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
|
||||
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,
|
||||
getCwd: () => cwd,
|
||||
getServerHome: () => toolsStore.serverHome ?? null,
|
||||
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
|
||||
getPickersRef: () => pickersRef
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -185,6 +188,7 @@
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
|
||||
@@ -200,13 +204,16 @@
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -238,6 +245,7 @@
|
||||
$effect(() => {
|
||||
const wantContenteditable =
|
||||
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
||||
|
||||
if (useContenteditable === wantContenteditable) return;
|
||||
|
||||
if (!caretOffsetPinned) {
|
||||
@@ -268,8 +276,10 @@
|
||||
export function checkModelSelected(): boolean {
|
||||
if (!hasModelSelected) {
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -284,6 +294,7 @@
|
||||
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);
|
||||
}
|
||||
@@ -333,6 +344,7 @@
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFilesAdd?.(files);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -354,26 +366,27 @@
|
||||
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];
|
||||
@@ -412,17 +425,17 @@
|
||||
|
||||
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];
|
||||
@@ -450,12 +463,12 @@
|
||||
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
|
||||
);
|
||||
@@ -480,9 +493,11 @@
|
||||
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;
|
||||
@@ -504,6 +519,7 @@
|
||||
async function handleMicClick() {
|
||||
if (!audioRecorder || !recordingSupported) {
|
||||
console.warn('Audio recording not supported');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -642,7 +658,7 @@
|
||||
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)}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
<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;
|
||||
|
||||
+16
-16
@@ -1,20 +1,20 @@
|
||||
<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 {
|
||||
@@ -36,15 +36,15 @@
|
||||
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);
|
||||
@@ -59,13 +59,13 @@
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasVisionModality,
|
||||
hasAudioModality,
|
||||
hasVideoModality,
|
||||
hasMcpPromptsSupport,
|
||||
hasMcpResourcesSupport
|
||||
hasMcpResourcesSupport,
|
||||
hasVideoModality,
|
||||
hasVisionModality
|
||||
}),
|
||||
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
|
||||
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
|
||||
() => {
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,15 +1,15 @@
|
||||
<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;
|
||||
@@ -24,10 +24,13 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<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();
|
||||
|
||||
+25
-25
@@ -1,30 +1,30 @@
|
||||
<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;
|
||||
@@ -45,14 +45,14 @@
|
||||
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();
|
||||
|
||||
@@ -64,13 +64,13 @@
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasVisionModality,
|
||||
hasAudioModality,
|
||||
hasVideoModality,
|
||||
hasMcpPromptsSupport,
|
||||
hasMcpResourcesSupport
|
||||
hasMcpResourcesSupport,
|
||||
hasVideoModality,
|
||||
hasVisionModality
|
||||
}),
|
||||
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
|
||||
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
|
||||
() => {
|
||||
sheetOpen = false;
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
<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);
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
@@ -21,9 +21,9 @@
|
||||
let {
|
||||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVideoModality = false,
|
||||
hasMcpPromptsSupport = false,
|
||||
hasMcpResourcesSupport = false,
|
||||
hasVideoModality = false,
|
||||
hasVisionModality = false,
|
||||
onFileUpload,
|
||||
onMcpPromptClick,
|
||||
|
||||
+7
-4
@@ -1,15 +1,15 @@
|
||||
<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;
|
||||
@@ -27,9 +27,9 @@
|
||||
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
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
let selectorModel = $derived.by(() => {
|
||||
const storeModel = selectedModelName();
|
||||
|
||||
if (storeModel && storeModel !== conversationModel) {
|
||||
return storeModel;
|
||||
}
|
||||
@@ -66,6 +67,7 @@
|
||||
modelsStore.selectedModelName = null;
|
||||
modelsStore.clearSelection();
|
||||
}
|
||||
|
||||
lastSyncedConversationModel = conversationModel;
|
||||
} else if (
|
||||
isRouter &&
|
||||
@@ -76,6 +78,7 @@
|
||||
) {
|
||||
lastSyncedConversationModel = null;
|
||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||
|
||||
if (first) modelsStore.selectModelById(first.id);
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
|
||||
+26
-16
@@ -1,28 +1,28 @@
|
||||
<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;
|
||||
@@ -51,15 +51,15 @@
|
||||
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());
|
||||
@@ -105,29 +105,39 @@
|
||||
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>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<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,
|
||||
@@ -19,10 +16,13 @@
|
||||
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;
|
||||
@@ -57,7 +57,9 @@
|
||||
// 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';
|
||||
}
|
||||
|
||||
@@ -93,23 +95,24 @@
|
||||
*/
|
||||
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(
|
||||
@@ -118,6 +121,7 @@
|
||||
document.createTextNode(trailing + '```')
|
||||
);
|
||||
highlightedSegments.set(el, segment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -136,9 +140,11 @@
|
||||
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;
|
||||
}
|
||||
@@ -146,11 +152,14 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -182,6 +191,7 @@
|
||||
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;
|
||||
|
||||
@@ -196,6 +206,7 @@
|
||||
if (!rootElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
@@ -212,6 +223,7 @@
|
||||
|
||||
const target = textOffsetToRange(rootElement, offset);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) return;
|
||||
|
||||
if (extend && selection.anchorNode) {
|
||||
@@ -221,6 +233,7 @@
|
||||
target.startContainer,
|
||||
target.startOffset
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -230,14 +243,16 @@
|
||||
|
||||
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
|
||||
);
|
||||
@@ -262,10 +277,12 @@
|
||||
// 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
|
||||
@@ -284,7 +301,9 @@
|
||||
// \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') &&
|
||||
@@ -302,7 +321,9 @@
|
||||
syncCodeBlockHatches(rootElement);
|
||||
|
||||
const serialized = serializeContent(rootElement);
|
||||
|
||||
syncEmptyState(serialized);
|
||||
|
||||
if (serialized === lastEmittedValue) return;
|
||||
|
||||
// Plain typing/deletes coalesce per time window; structural edits
|
||||
@@ -316,6 +337,7 @@
|
||||
// completed or broken) - the browser-owned text nodes cannot
|
||||
// restyle themselves across element boundaries.
|
||||
const tokens = tokenizeContent(serialized);
|
||||
|
||||
if (!domMatchesTokens(rootElement, tokens)) {
|
||||
renderTokens(tokens);
|
||||
|
||||
@@ -324,6 +346,7 @@
|
||||
// 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;
|
||||
@@ -361,6 +384,7 @@
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
|
||||
if (!range) return;
|
||||
|
||||
if (!range.collapsed) {
|
||||
@@ -374,16 +398,22 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -392,6 +422,7 @@
|
||||
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) {
|
||||
@@ -405,6 +436,7 @@
|
||||
|
||||
const selection = window.getSelection();
|
||||
const after = document.createRange();
|
||||
|
||||
after.setStartAfter(nl);
|
||||
after.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
@@ -428,9 +460,11 @@
|
||||
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
|
||||
@@ -439,16 +473,19 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -464,14 +501,17 @@
|
||||
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();
|
||||
}
|
||||
@@ -491,6 +531,7 @@
|
||||
*/
|
||||
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;
|
||||
@@ -499,11 +540,13 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -524,6 +567,7 @@
|
||||
// stuck on the old line (see insertLineBreak).
|
||||
event.preventDefault();
|
||||
insertLineBreak();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -543,6 +587,7 @@
|
||||
// re-tokenize/re-highlight follows.
|
||||
event.preventDefault();
|
||||
document.execCommand('insertLineBreak');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -555,6 +600,7 @@
|
||||
) {
|
||||
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -574,6 +620,7 @@
|
||||
if (target !== null) {
|
||||
event.preventDefault();
|
||||
restoreCaret(target, event.shiftKey);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -586,6 +633,7 @@
|
||||
// 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;
|
||||
@@ -602,6 +650,7 @@
|
||||
*/
|
||||
function handlePasteEvent(event: ClipboardEvent) {
|
||||
const pasted = event.clipboardData?.getData('text/plain');
|
||||
|
||||
if (pasted && pasted.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -609,6 +658,7 @@
|
||||
// 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));
|
||||
}
|
||||
@@ -621,6 +671,7 @@
|
||||
// consumes the event (files, quoted prompts, long text).
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
onPaste?.(event);
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
handlePasteEvent(event);
|
||||
}
|
||||
@@ -634,20 +685,23 @@
|
||||
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);
|
||||
@@ -656,6 +710,7 @@
|
||||
|
||||
function handleCut(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
@@ -675,6 +730,7 @@
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
|
||||
if (!isMobile.current) {
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -689,6 +745,7 @@
|
||||
// browser already owns the right shape.
|
||||
$effect(() => {
|
||||
const incoming = value ?? '';
|
||||
|
||||
if (incoming === lastEmittedValue) return;
|
||||
|
||||
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
|
||||
@@ -702,6 +759,7 @@
|
||||
|
||||
export function getCaretOffset(): number {
|
||||
if (!rootElement) return 0;
|
||||
|
||||
return rangeToTextOffset(rootElement, safeRange());
|
||||
}
|
||||
|
||||
@@ -710,11 +768,13 @@
|
||||
if (rootElement && rootElement !== document.activeElement) {
|
||||
rootElement.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
restoreCaret(offset);
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (isMobile.current) return;
|
||||
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -1,9 +1,7 @@
|
||||
<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,
|
||||
@@ -11,22 +9,28 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
let { label, value, subtitle }: Props = $props();
|
||||
let { label, subtitle, value }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid gap-1.5">
|
||||
|
||||
+9
-9
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
@@ -18,15 +18,15 @@
|
||||
}
|
||||
|
||||
let {
|
||||
currentRead,
|
||||
currentFresh,
|
||||
currentCache,
|
||||
currentOutput,
|
||||
kvTotal,
|
||||
cumulativeRead,
|
||||
cumulativeOutput,
|
||||
cumulativeCacheTotal,
|
||||
averageTokensPerSecond,
|
||||
cumulativeCacheTotal,
|
||||
cumulativeOutput,
|
||||
cumulativeRead,
|
||||
currentCache,
|
||||
currentFresh,
|
||||
currentOutput,
|
||||
currentRead,
|
||||
kvTotal,
|
||||
transientDetails
|
||||
}: Props = $props();
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
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;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
onLoad: () => void;
|
||||
}
|
||||
|
||||
let { modelId, isLoading, onLoad }: Props = $props();
|
||||
let { isLoading, modelId, onLoad }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if modelId !== null && !isLoading}
|
||||
|
||||
+9
-4
@@ -1,15 +1,15 @@
|
||||
<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();
|
||||
|
||||
@@ -30,13 +30,18 @@
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ const CRITICAL_THRESHOLD = 95;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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;
|
||||
|
||||
+17
-10
@@ -1,14 +1,14 @@
|
||||
<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.
|
||||
@@ -24,12 +24,12 @@
|
||||
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());
|
||||
@@ -51,20 +51,24 @@
|
||||
|
||||
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(() => {
|
||||
@@ -76,8 +80,10 @@
|
||||
$effect(() => {
|
||||
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredCommands[nav.hoveredIndex].disabled) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
@@ -85,6 +91,7 @@
|
||||
|
||||
function handleSelect(command: ChatFormCommand) {
|
||||
if (command.disabled) return;
|
||||
|
||||
onSelect(command);
|
||||
onClose();
|
||||
}
|
||||
|
||||
+33
-18
@@ -1,22 +1,22 @@
|
||||
<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
|
||||
@@ -38,18 +38,18 @@
|
||||
|
||||
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])
|
||||
});
|
||||
@@ -69,6 +69,7 @@
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -78,8 +79,8 @@
|
||||
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 {
|
||||
@@ -91,23 +92,29 @@
|
||||
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);
|
||||
}
|
||||
@@ -121,9 +128,11 @@
|
||||
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';
|
||||
});
|
||||
|
||||
@@ -131,6 +140,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
@@ -146,12 +156,15 @@
|
||||
|
||||
$effect(() => {
|
||||
const q = (query ?? '').trim();
|
||||
|
||||
if (!isOpen || !q || !fileSearchEnabled) {
|
||||
search.cancel();
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
search.setLoading(true);
|
||||
search.run(q);
|
||||
});
|
||||
@@ -167,9 +180,11 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
@@ -12,7 +12,7 @@
|
||||
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>
|
||||
|
||||
+19
-19
@@ -1,9 +1,9 @@
|
||||
<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[];
|
||||
@@ -28,22 +28,22 @@
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
isLoading,
|
||||
selectedIndex,
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage,
|
||||
autofocus = false,
|
||||
inputRef = $bindable(null),
|
||||
onSearchClose,
|
||||
itemKey,
|
||||
item,
|
||||
skeleton,
|
||||
skeletonCount = 6,
|
||||
emptyMessage,
|
||||
footer,
|
||||
scrollTrigger
|
||||
inputRef = $bindable(null),
|
||||
isLoading,
|
||||
item,
|
||||
itemKey,
|
||||
items,
|
||||
onSearchClose,
|
||||
scrollTrigger,
|
||||
searchPlaceholder = 'Search...',
|
||||
searchQuery = $bindable(),
|
||||
selectedIndex,
|
||||
showSearchInput,
|
||||
skeleton,
|
||||
skeletonCount = 6
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
@@ -55,11 +55,11 @@
|
||||
// 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>
|
||||
|
||||
|
||||
+5
-5
@@ -12,13 +12,13 @@
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = '',
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
onclick,
|
||||
onmouseenter,
|
||||
dataIndex,
|
||||
children
|
||||
disabled = false,
|
||||
isSelected = false,
|
||||
onclick,
|
||||
onmouseenter
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
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">
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
<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;
|
||||
@@ -12,12 +12,12 @@
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = '',
|
||||
isOpen = $bindable(false),
|
||||
srLabel = 'Open picker',
|
||||
onClose,
|
||||
onKeydown,
|
||||
children
|
||||
srLabel = 'Open picker'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
|
||||
+26
-19
@@ -1,19 +1,19 @@
|
||||
<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;
|
||||
@@ -32,11 +32,11 @@
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError
|
||||
onPromptLoadError,
|
||||
onPromptLoadStart,
|
||||
searchQuery = ''
|
||||
}: Props = $props();
|
||||
|
||||
let prompts = $state<MCPPromptInfo[]>([]);
|
||||
@@ -89,7 +89,6 @@
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
@@ -118,6 +117,7 @@
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
|
||||
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
@@ -131,7 +131,6 @@
|
||||
promptError = null;
|
||||
|
||||
const placeholderId = uuid();
|
||||
|
||||
const nonEmptyArgs = Object.fromEntries(
|
||||
Object.entries(args).filter(([, value]) => value.trim() !== '')
|
||||
);
|
||||
@@ -142,10 +141,12 @@
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -167,9 +168,9 @@
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -187,9 +188,9 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,6 +235,7 @@
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancelArgumentForm();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,6 +276,7 @@
|
||||
selectedIndex = selectedIndexBeforeArgumentForm;
|
||||
selectedIndexBeforeArgumentForm = null;
|
||||
}
|
||||
|
||||
selectedPrompt = null;
|
||||
promptArgs = {};
|
||||
promptError = null;
|
||||
@@ -284,6 +287,7 @@
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
|
||||
if (selectedPrompt) {
|
||||
// Return to prompt selection list, keeping the selected prompt active
|
||||
handleCancelArgumentForm();
|
||||
@@ -296,6 +300,7 @@
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
|
||||
scrollTrigger++;
|
||||
@@ -306,6 +311,7 @@
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
|
||||
scrollTrigger++;
|
||||
@@ -316,6 +322,7 @@
|
||||
|
||||
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredPrompts[selectedIndex]) {
|
||||
handlePromptClick(filteredPrompts[selectedIndex]);
|
||||
}
|
||||
@@ -329,14 +336,14 @@
|
||||
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(
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
@@ -21,20 +21,20 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
<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];
|
||||
|
||||
@@ -22,16 +22,16 @@
|
||||
|
||||
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>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
{#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
|
||||
|
||||
+11
-11
@@ -35,24 +35,24 @@
|
||||
}
|
||||
|
||||
let {
|
||||
isCommandPickerOpen,
|
||||
commandQuery,
|
||||
commands = [],
|
||||
isCommandPickerOpen,
|
||||
isMentionPickerOpen,
|
||||
isPromptPickerOpen,
|
||||
mentionAnchor,
|
||||
mentionQuery,
|
||||
onCommandPickerClose,
|
||||
onCommandSelect,
|
||||
isPromptPickerOpen,
|
||||
promptSearchQuery,
|
||||
isMentionPickerOpen,
|
||||
mentionQuery,
|
||||
mentionAnchor,
|
||||
scopePath,
|
||||
onPromptPickerClose,
|
||||
onMentionPickerClose,
|
||||
onMentionOpened,
|
||||
onMentionPickerClose,
|
||||
onMentionSelect,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError
|
||||
onPromptLoadError,
|
||||
onPromptLoadStart,
|
||||
onPromptPickerClose,
|
||||
promptSearchQuery,
|
||||
scopePath
|
||||
}: Props = $props();
|
||||
|
||||
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
<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,
|
||||
@@ -28,6 +14,20 @@
|
||||
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;
|
||||
@@ -52,14 +52,14 @@
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
directory = null,
|
||||
isOpen,
|
||||
query = $bindable(''),
|
||||
customAnchor = null,
|
||||
directory = null,
|
||||
disabled = false,
|
||||
isOpen,
|
||||
onChange,
|
||||
onClose,
|
||||
onOpen
|
||||
onOpen,
|
||||
query = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
|
||||
@@ -88,8 +88,8 @@
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => queryResults.length,
|
||||
isOpen: () => isOpen,
|
||||
onClose: closePicker,
|
||||
onSelect: (index) => commit(queryResults[index])
|
||||
});
|
||||
@@ -99,19 +99,24 @@
|
||||
// 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 {
|
||||
@@ -124,11 +129,11 @@
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -136,16 +141,18 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -160,25 +167,31 @@
|
||||
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);
|
||||
@@ -197,7 +210,9 @@
|
||||
|
||||
function setDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
onChange?.(trimmed);
|
||||
}
|
||||
|
||||
@@ -207,17 +222,18 @@
|
||||
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;
|
||||
@@ -226,9 +242,11 @@
|
||||
|
||||
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();
|
||||
@@ -240,16 +258,20 @@
|
||||
} 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();
|
||||
}
|
||||
@@ -257,6 +279,7 @@
|
||||
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) {
|
||||
@@ -287,6 +310,7 @@
|
||||
function handleDismiss(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (directory) {
|
||||
clearDirectory(event);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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;
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
let {
|
||||
directory = null,
|
||||
homeBase = null,
|
||||
disabled = false,
|
||||
showTooltip = false,
|
||||
onClear
|
||||
homeBase = null,
|
||||
onClear,
|
||||
showTooltip = false
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
@@ -20,21 +20,21 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<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;
|
||||
@@ -32,12 +32,12 @@
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
toolMessages = [],
|
||||
isLastAssistantMessage = false,
|
||||
isLastUserMessage = false,
|
||||
message,
|
||||
nextAssistantMessage = null,
|
||||
siblingInfo = null
|
||||
siblingInfo = null,
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
const chatActions = getChatActionsContext();
|
||||
@@ -72,10 +72,12 @@
|
||||
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:
|
||||
@@ -115,9 +117,7 @@
|
||||
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
|
||||
|
||||
setMessageEditContext({
|
||||
get isEditing() {
|
||||
return isEditing;
|
||||
},
|
||||
cancel: handleCancelEdit,
|
||||
get editedContent() {
|
||||
return editedContent;
|
||||
},
|
||||
@@ -127,6 +127,12 @@
|
||||
get editedUploadedFiles() {
|
||||
return editedUploadedFiles;
|
||||
},
|
||||
get isEditing() {
|
||||
return isEditing;
|
||||
},
|
||||
get messageRole() {
|
||||
return message.role;
|
||||
},
|
||||
get originalContent() {
|
||||
return message.role === MessageRole.ASSISTANT
|
||||
? (rawEditContent ?? message.content)
|
||||
@@ -135,42 +141,40 @@
|
||||
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;
|
||||
},
|
||||
setUploadedFiles: (files: ChatUploadedFile[]) => {
|
||||
editedUploadedFiles = files;
|
||||
},
|
||||
setShouldBranchAfterEdit: (value: boolean) => {
|
||||
shouldBranchAfterEdit = value;
|
||||
},
|
||||
save: handleSaveEdit,
|
||||
saveOnly: handleSaveEditOnly,
|
||||
cancel: handleCancelEdit,
|
||||
setUploadedFiles: (files: ChatUploadedFile[]) => {
|
||||
editedUploadedFiles = files;
|
||||
},
|
||||
get shouldBranchAfterEdit() {
|
||||
return shouldBranchAfterEdit;
|
||||
},
|
||||
get showBranchAfterEditOption() {
|
||||
return showBranchAfterEditOption;
|
||||
},
|
||||
get showSaveOnlyOption() {
|
||||
return showSaveOnlyOption;
|
||||
},
|
||||
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];
|
||||
@@ -238,6 +242,7 @@
|
||||
|
||||
function handleEdit() {
|
||||
isEditing = true;
|
||||
|
||||
// Clear temporary placeholder content for system messages
|
||||
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
|
||||
editedContent = '';
|
||||
@@ -281,6 +286,7 @@
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -292,23 +298,29 @@
|
||||
// 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
|
||||
@@ -325,6 +337,7 @@
|
||||
if (message.role === MessageRole.USER) {
|
||||
// For user messages, trim to avoid accidental whitespace
|
||||
const finalExtras = await getMergedExtras();
|
||||
|
||||
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageActionIcons,
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageAssistantModel,
|
||||
ChatMessageAssistantProcessingInfo,
|
||||
ChatMessageAssistantRawOutput,
|
||||
@@ -9,14 +9,13 @@
|
||||
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 {
|
||||
@@ -49,7 +48,6 @@
|
||||
deletionInfo,
|
||||
isLastAssistantMessage = false,
|
||||
message,
|
||||
toolMessages = [],
|
||||
onConfirmDelete,
|
||||
onContinue,
|
||||
onCopy,
|
||||
@@ -61,7 +59,8 @@
|
||||
onShowDeleteDialogChange,
|
||||
showDeleteDialog,
|
||||
siblingInfo = null,
|
||||
textareaElement = $bindable()
|
||||
textareaElement = $bindable(),
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
// Get edit context
|
||||
@@ -124,18 +123,21 @@
|
||||
|
||||
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 () => {
|
||||
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
<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;
|
||||
@@ -11,7 +11,7 @@
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
}
|
||||
|
||||
let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
|
||||
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
|
||||
|
||||
let pendingModel = $state<string | null>(null);
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<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;
|
||||
@@ -8,7 +8,7 @@
|
||||
position: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let { modelLoadingText, processingState, position }: Props = $props();
|
||||
let { modelLoadingText, position, processingState }: Props = $props();
|
||||
|
||||
const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
|
||||
</script>
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
|
||||
import { buildAssistantRawOutput, deriveAgenticSections } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
message: DatabaseMessage;
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
let rawOutputContent = $derived.by(() => {
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
|
||||
return buildAssistantRawOutput(sections);
|
||||
});
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
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}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
|
||||
+9
-9
@@ -5,7 +5,7 @@
|
||||
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 {
|
||||
@@ -30,17 +30,17 @@
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
mcpPrompt,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
mcpPrompt,
|
||||
message,
|
||||
onConfirmDelete,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange
|
||||
onShowDeleteDialogChange,
|
||||
showDeleteDialog,
|
||||
siblingInfo = null
|
||||
}: Props = $props();
|
||||
|
||||
// Get edit context
|
||||
|
||||
+16
-13
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
import { TruncatedText } from '$lib/components/app/misc';
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
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';
|
||||
|
||||
interface ContentPart {
|
||||
text: string;
|
||||
@@ -22,10 +22,10 @@
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
prompt,
|
||||
variant = McpPromptVariant.MESSAGE,
|
||||
isLoading = false,
|
||||
loadError
|
||||
loadError,
|
||||
prompt,
|
||||
variant = McpPromptVariant.MESSAGE
|
||||
}: Props = $props();
|
||||
|
||||
let hoveredArgKey = $state<string | null>(null);
|
||||
@@ -35,13 +35,15 @@
|
||||
|
||||
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);
|
||||
@@ -55,20 +57,21 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
|
||||
+6
-6
@@ -31,16 +31,16 @@
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
message,
|
||||
onConfirmDelete,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange,
|
||||
showDeleteDialog,
|
||||
siblingInfo = null,
|
||||
textareaElement = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
|
||||
+9
-9
@@ -1,12 +1,4 @@
|
||||
<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';
|
||||
@@ -18,6 +10,14 @@
|
||||
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;
|
||||
@@ -28,7 +28,7 @@
|
||||
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));
|
||||
|
||||
+7
-7
@@ -3,19 +3,19 @@
|
||||
// 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;
|
||||
@@ -25,7 +25,7 @@
|
||||
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));
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
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 { XCircle } from '@lucide/svelte';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { abbreviateHome, type AgenticSection, computeLineDiff, prefixFor } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
@@ -13,7 +13,7 @@
|
||||
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);
|
||||
|
||||
+19
-11
@@ -6,26 +6,26 @@
|
||||
// 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;
|
||||
@@ -39,7 +39,7 @@
|
||||
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)
|
||||
@@ -108,6 +108,7 @@
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
if (!scrollEl) return false;
|
||||
|
||||
return (
|
||||
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
|
||||
SCROLL_BOTTOM_THRESHOLD_PX
|
||||
@@ -116,6 +117,7 @@
|
||||
|
||||
function scrollToBottomOnFrame() {
|
||||
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
|
||||
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null;
|
||||
|
||||
@@ -128,18 +130,23 @@
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -149,10 +156,11 @@
|
||||
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();
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
<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;
|
||||
@@ -12,7 +12,7 @@
|
||||
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);
|
||||
|
||||
+4
-1
@@ -8,7 +8,7 @@
|
||||
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);
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
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 {
|
||||
|
||||
+7
-4
@@ -1,15 +1,15 @@
|
||||
<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);
|
||||
@@ -26,12 +26,15 @@
|
||||
|
||||
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 {
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
<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;
|
||||
@@ -12,7 +12,7 @@
|
||||
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);
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<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;
|
||||
@@ -12,7 +12,7 @@
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
let { isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const readFileMeta = $derived(parseReadFileMeta(section));
|
||||
</script>
|
||||
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
<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;
|
||||
@@ -14,7 +14,7 @@
|
||||
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 ?? '');
|
||||
|
||||
+11
-7
@@ -1,17 +1,17 @@
|
||||
<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 {
|
||||
@@ -21,7 +21,7 @@
|
||||
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);
|
||||
@@ -43,6 +43,7 @@
|
||||
// retrospective.
|
||||
const title = $derived.by(() => {
|
||||
const verb = showSpinner ? 'Searching' : 'Searched';
|
||||
|
||||
return query ? `${verb} web for "${query}"` : `${verb} web`;
|
||||
});
|
||||
|
||||
@@ -52,13 +53,16 @@
|
||||
|
||||
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;
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
<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;
|
||||
@@ -14,7 +14,7 @@
|
||||
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);
|
||||
|
||||
+14
-11
@@ -11,12 +11,12 @@
|
||||
|
||||
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 };
|
||||
|
||||
@@ -64,17 +64,17 @@
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -102,8 +102,11 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -122,9 +125,9 @@
|
||||
{onToggle}
|
||||
>
|
||||
{@render children(meta, {
|
||||
isStreaming,
|
||||
isCodeStreaming,
|
||||
isPending,
|
||||
isStreamingCall,
|
||||
isCodeStreaming
|
||||
isStreaming,
|
||||
isStreamingCall
|
||||
})}
|
||||
</Wrapper>
|
||||
|
||||
+4
-1
@@ -5,8 +5,8 @@
|
||||
// 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
|
||||
@@ -17,9 +17,11 @@ import type { AgenticSection } from '$lib/utils/agentic';
|
||||
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;
|
||||
@@ -43,6 +45,7 @@ export function parseToolArgs(
|
||||
options: { partial?: boolean } = {}
|
||||
): Record<string, unknown> | null {
|
||||
if (section.toolName !== expected || !section.toolArgs) return null;
|
||||
|
||||
return options.partial
|
||||
? parsePartialJsonArgs(section.toolArgs)
|
||||
: parseFinalToolArgs(section.toolArgs);
|
||||
|
||||
+18
-9
@@ -3,10 +3,10 @@
|
||||
// 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;
|
||||
@@ -24,48 +24,57 @@ export type EditFileMeta = {
|
||||
|
||||
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 {
|
||||
edits,
|
||||
editsApplied,
|
||||
errorMessage,
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
edits,
|
||||
resultMessage,
|
||||
editsApplied,
|
||||
errorMessage
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
+4
-1
@@ -5,9 +5,9 @@
|
||||
// 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;
|
||||
@@ -15,9 +15,12 @@ export type ExecShellCommandMeta = {
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
+10
-3
@@ -4,9 +4,9 @@
|
||||
// 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;
|
||||
@@ -19,11 +19,13 @@ export type FileGlobSearchMeta = {
|
||||
|
||||
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[] = [];
|
||||
@@ -31,17 +33,21 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -50,9 +56,10 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
|
||||
const split = splitSearchSummaryList(toolResultString, (total) => {
|
||||
totalMatches = total;
|
||||
});
|
||||
|
||||
matches = split.lines;
|
||||
}
|
||||
}
|
||||
|
||||
return { path, include, exclude, matches, totalMatches, errorMessage };
|
||||
return { errorMessage, exclude, include, matches, path, totalMatches };
|
||||
}
|
||||
|
||||
+24
-12
@@ -5,9 +5,9 @@
|
||||
// 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;
|
||||
@@ -28,10 +28,12 @@ export type GrepSearchMeta = {
|
||||
|
||||
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 : '**';
|
||||
@@ -43,17 +45,21 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -64,19 +70,20 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,24 +92,29 @@ function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch
|
||||
// <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
|
||||
};
|
||||
}
|
||||
|
||||
+11
-7
@@ -3,14 +3,14 @@
|
||||
// `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;
|
||||
@@ -20,13 +20,14 @@ export type ReadFileMeta = {
|
||||
|
||||
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;
|
||||
@@ -34,19 +35,22 @@ export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null
|
||||
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 };
|
||||
}
|
||||
|
||||
+10
-2
@@ -5,9 +5,9 @@
|
||||
// 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;
|
||||
@@ -17,30 +17,37 @@ export type RunJavascriptMeta = {
|
||||
|
||||
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) {
|
||||
@@ -48,9 +55,10 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe
|
||||
.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 };
|
||||
}
|
||||
|
||||
+9
-8
@@ -3,14 +3,14 @@
|
||||
// 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;
|
||||
@@ -24,18 +24,19 @@ export type WriteFileMeta = {
|
||||
|
||||
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;
|
||||
@@ -43,12 +44,12 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
|
||||
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
|
||||
|
||||
return {
|
||||
bytesWritten,
|
||||
content,
|
||||
errorMessage,
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
language,
|
||||
content,
|
||||
bytesWritten,
|
||||
resultMessage,
|
||||
errorMessage
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
+11
-10
@@ -6,9 +6,9 @@
|
||||
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 {
|
||||
@@ -35,19 +35,19 @@
|
||||
|
||||
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
|
||||
@@ -60,13 +60,14 @@
|
||||
// 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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
<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';
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
if (content.includes('\n')) {
|
||||
isMultiline = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
<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 {
|
||||
@@ -16,9 +16,9 @@
|
||||
class: className = '',
|
||||
content,
|
||||
extras = [],
|
||||
onSendImmediately,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDelete
|
||||
onSendImmediately
|
||||
}: Props = $props();
|
||||
|
||||
const editCtx = useMessageEditContext({
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<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 }>;
|
||||
@@ -8,7 +8,7 @@
|
||||
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">
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user