// 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'
+ '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'
}
},
{
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
- "@chromatic-com/storybook": "^4.1.2",
+ "@chromatic-com/storybook": "^5.0.0",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.515.0",
"@playwright/test": "^1.49.1",
- "@storybook/addon-a11y": "^10.0.7",
- "@storybook/addon-docs": "^10.0.7",
+ "@storybook/addon-a11y": "^10.2.4",
+ "@storybook/addon-docs": "^10.2.4",
"@storybook/addon-svelte-csf": "^5.0.10",
- "@storybook/addon-vitest": "^10.0.7",
- "@storybook/sveltekit": "^10.0.7",
+ "@storybook/addon-vitest": "^10.2.4",
+ "@storybook/sveltekit": "^10.2.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
- "@types/node": "^22",
+ "@types/node": "^24",
"@vitest/browser": "^3.2.3",
+ "@vitest/coverage-v8": "^3.2.3",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
- "eslint-plugin-storybook": "^10.0.7",
+ "eslint-plugin-storybook": "^10.2.4",
"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sass": "^1.93.3",
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"svelte": "^5.38.2",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"node": ">=6.9.0"
}
},
- "node_modules/@babel/helper-validator-identifier": {
+ "node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@chromatic-com/storybook": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.2.tgz",
- "integrity": "sha512-QAWGtHwib0qsP5CcO64aJCF75zpFgpKK3jNpxILzQiPK3sVo4EmnVGJVdwcZWpWrGdH8E4YkncGoitw4EXzKMg==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz",
+ "integrity": "sha512-8wUsqL8kg6R5ue8XNE7Jv/iD1SuE4+6EXMIGIuE+T2loBITEACLfC3V8W44NJviCLusZRMWbzICddz0nU0bFaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@neoconfetti/react": "^1.0.0",
- "chromatic": "^12.0.0",
+ "chromatic": "^13.3.4",
"filesize": "^10.0.12",
"jsonfile": "^6.1.0",
"strip-ansi": "^7.1.0"
"yarn": ">=1.22.18"
},
"peerDependencies": {
- "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0"
+ "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0"
}
},
"node_modules/@esbuild/aix-ppc64": {
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
- "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
}
},
"node_modules/@eslint/compat": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz",
- "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==",
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz",
+ "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
}
},
"node_modules/@eslint/config-array": {
- "version": "0.21.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
- "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.6",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
- "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
- "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
+ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
+ "js-yaml": "^4.1.1",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
}
},
"node_modules/@eslint/js": {
- "version": "9.31.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
- "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
+ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
- "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
- "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.2",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"@swc/helpers": "^0.5.0"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"node": ">=18.0.0"
}
},
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.29",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
- "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"dev": true,
"license": "MIT"
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"node": ">=0.10"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"license": "MIT"
},
"node_modules/@storybook/addon-a11y": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.0.7.tgz",
- "integrity": "sha512-JsYPpZ/n67/2bI1XJeyrAWHHQkHemPkPHjCA0tAUnMz1Shlo/LV2q1Ahgpxoihx4strbHwZz71bcS4MqkHBduA==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.4.tgz",
+ "integrity": "sha512-VGhdZ+iP2l/CSulIKV2kt3SMWVHntOigqWqGkNYf6YNYofynUYEKdsNqBvHx4ySuNEl/eXJ8LRO8FKYnU7LxZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^10.0.7"
+ "storybook": "^10.2.4"
}
},
"node_modules/@storybook/addon-docs": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.0.7.tgz",
- "integrity": "sha512-qQQMoeYZC4W+/8ubfOZiTrE8nYC/f4wWP1uq4peRyDy1N2nIN9SwhyxwMn0m3VpeGmRBga5dLvJY9ko6SnJekg==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.4.tgz",
+ "integrity": "sha512-FzscAmdBiOGnGrxiEM+8eTg43kjqgjLfObg+lbJVRR/a0DmZ3xfAPNB0+VKYQbN0FacNcWLM9LZ/7U0hRBPBnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
- "@storybook/csf-plugin": "10.0.7",
- "@storybook/icons": "^1.6.0",
- "@storybook/react-dom-shim": "10.0.7",
+ "@storybook/csf-plugin": "10.2.4",
+ "@storybook/icons": "^2.0.1",
+ "@storybook/react-dom-shim": "10.2.4",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^10.0.7"
+ "storybook": "^10.2.4"
}
},
"node_modules/@storybook/addon-svelte-csf": {
}
},
"node_modules/@storybook/addon-vitest": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.0.7.tgz",
- "integrity": "sha512-i6v/mAl+elrUxb+1f4NdnM17t/fg+KGJWL1U9quflXTd3KiLY0xJB4LwNP6yYo7Imc5NIO2fRkJbGvNqLBRe2Q==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.4.tgz",
+ "integrity": "sha512-BT1iP89U4wcbpzTURU8WYTAeUcdNh4WIt0BqsnATmMwR/jKNJW6QgXCVqGQTSpRjWj40hX5e2JkQYCNXdjKsPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
- "@storybook/icons": "^1.6.0",
- "prompts": "^2.4.0",
- "ts-dedent": "^2.2.0"
+ "@storybook/icons": "^2.0.1"
},
"funding": {
"type": "opencollective",
"@vitest/browser": "^3.0.0 || ^4.0.0",
"@vitest/browser-playwright": "^4.0.0",
"@vitest/runner": "^3.0.0 || ^4.0.0",
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"vitest": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
}
},
"node_modules/@storybook/builder-vite": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.0.7.tgz",
- "integrity": "sha512-wk2TAoUY5+9t78GWVBndu9rEo9lo6Ec3SRrLT4VpIlcS2GPK+5f26UC2uvIBwOF/N7JrUUKq/zWDZ3m+do9QDg==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.4.tgz",
+ "integrity": "sha512-/hcT1xj3CL5GkJ5v5/EguZdttDwNE6weNXK7vKzp034tnGcLycOossDsTiUQkBowSL+Ylc8aKj+ZgvddPNfOig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/csf-plugin": "10.0.7",
+ "@storybook/csf-plugin": "10.2.4",
"ts-dedent": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
}
},
"node_modules/@storybook/csf-plugin": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.0.7.tgz",
- "integrity": "sha512-YaYYlCyJBwxaMk7yREOdz+9MDSgxIYGdeJ9EIq/bUndmkoj9SRo1P9/0lC5dseWQoiGy4T3PbZiWruD8uM5m3g==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.4.tgz",
+ "integrity": "sha512-kupPQEV+4N9mzsZHYaokvhO/KHBjYdWda9PNmPQwy0TR7r2mzthgaNH72TjmgN1L6DIbsuyOG1wtczcPJn4+Jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"vite": "*",
"webpack": "*"
},
"license": "MIT"
},
"node_modules/@storybook/icons": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz",
- "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz",
+ "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- },
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta"
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@storybook/react-dom-shim": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.0.7.tgz",
- "integrity": "sha512-bp4OnMtZGwPJQDqNRi4K5iibLbZ2TZZMkWW7oSw5jjPFpGSreSjCe8LH9yj/lDnK8Ox9bGMCBFE5RV5XuML29w==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.4.tgz",
+ "integrity": "sha512-i22OtrZ7GeZPt/odLf0vqyDhRSKyaLsHkkKSBcANQfzRRnBZmiz2FchOtWm9uvoDWybQsTruZq7kTdtpEhwyGw==",
"dev": true,
"license": "MIT",
"funding": {
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "storybook": "^10.0.7"
+ "storybook": "^10.2.4"
}
},
"node_modules/@storybook/svelte": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.0.7.tgz",
- "integrity": "sha512-rO+YQhHucy47Vh67z318pALmd6x+K1Kj30Fb4a6oOEw4xn4zCo9KTmkMWs24c4oduEXD/eJu3badlRmsVXzyfA==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.2.4.tgz",
+ "integrity": "sha512-W9R51zUCd2iHOQBg/D93+bdpYv6kbtFx+kft5X8lPKQl6yEu0aKs9i5N5GyCASOhIApgx/tkqZIJ7vgM4cqrHA==",
"dev": true,
"license": "MIT",
"peer": true,
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"svelte": "^5.0.0"
}
},
"node_modules/@storybook/svelte-vite": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.0.7.tgz",
- "integrity": "sha512-q9/RtrhX1CnznO6AO9MDEy1bsccbGeRxW28FLpgUrztV4IGZ/dFUrFIFurKRyuA3/nFsbtzp1F5jFt3RExmmTw==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.2.4.tgz",
+ "integrity": "sha512-FMgKMRdoZFDwPD6eIDMldcgp6d6NtIGuXyUJjb29qLias/gE5TI6hg+cWmmWXQRTrXwdyepeMBmIfRcZbB6REQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/builder-vite": "10.0.7",
- "@storybook/svelte": "10.0.7",
+ "@storybook/builder-vite": "10.2.4",
+ "@storybook/svelte": "10.2.4",
"magic-string": "^0.30.0",
"svelte2tsx": "^0.7.44",
"typescript": "^4.9.4 || ^5.0.0"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"svelte": "^5.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@storybook/sveltekit": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.0.7.tgz",
- "integrity": "sha512-ujTW7PfWvgBrzd7jzaZe9JgjUeM5YvBKm+xru6t7Dr4bdfmkKqlZHPRdXn/sy+fQNyfg6JL2WKy2KIIeA+RvSg==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.2.4.tgz",
+ "integrity": "sha512-1qDX35iSJHWo1AOd7HMzJtCHBfgahXqTWNiyZa/JMEKJ3qC1otaU8XMmTjsZ6fCRF99piNdgqtWM8+s1TJOldg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/builder-vite": "10.0.7",
- "@storybook/svelte": "10.0.7",
- "@storybook/svelte-vite": "10.0.7"
+ "@storybook/builder-vite": "10.2.4",
+ "@storybook/svelte": "10.2.4",
+ "@storybook/svelte-vite": "10.2.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"svelte": "^5.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.49.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
- "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
+ "version": "2.52.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
+ "integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
"dev": true,
"license": "MIT",
"peer": true,
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
- "devalue": "^5.3.2",
+ "devalue": "^5.6.2",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"sade": "^1.8.1",
- "set-cookie-parser": "^2.6.0",
+ "set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
"bin": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
+ },
+ "typescript": {
+ "optional": true
}
}
},
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.16.5",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
- "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
+ "version": "24.10.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz",
+ "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/react": {
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
- "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
+ "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/type-utils": "8.37.0",
- "@typescript-eslint/utils": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/type-utils": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.37.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "@typescript-eslint/parser": "^8.56.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
- "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
+ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
- "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
+ "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.37.0",
- "@typescript-eslint/types": "^8.37.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.56.0",
+ "@typescript-eslint/types": "^8.56.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz",
- "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
+ "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0"
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz",
- "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
+ "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
"engines": {
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz",
- "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
+ "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/utils": "8.37.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz",
- "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
+ "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"license": "MIT",
"engines": {
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz",
- "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
+ "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.37.0",
- "@typescript-eslint/tsconfig-utils": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/visitor-keys": "8.37.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.56.0",
+ "@typescript-eslint/tsconfig-utils": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz",
- "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
+ "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.37.0",
- "@typescript-eslint/types": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz",
- "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
+ "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.37.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.56.0",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
+ "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
}
}
},
- "node_modules/@vitest/mocker/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"node": ">=4"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
+ "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
}
},
"node_modules/bits-ui": {
- "version": "2.14.4",
- "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz",
- "integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==",
+ "version": "2.15.7",
+ "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.7.tgz",
+ "integrity": "sha512-M9VrQAJXnT3xfhN/joEtVXhO794yBPmadZfNtDT4t4QwI8wgCBmDuv8FlH6K4v0q0Ugw07tumAPfym9MU2BGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"node": ">=8"
}
},
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
}
},
"node_modules/chromatic": {
- "version": "12.2.0",
- "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-12.2.0.tgz",
- "integrity": "sha512-GswmBW9ZptAoTns1BMyjbm55Z7EsIJnUvYKdQqXIBZIKbGErmpA+p4c0BYA+nzw5B0M+rb3Iqp1IaH8TFwIQew==",
+ "version": "13.3.5",
+ "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz",
+ "integrity": "sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==",
"dev": true,
"license": "MIT",
"bin": {
"license": "MIT"
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
"node": ">=0.10.0"
}
},
+ "node_modules/default-browser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
}
},
"node_modules/eslint": {
- "version": "9.31.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
- "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
+ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.21.0",
- "@eslint/config-helpers": "^0.3.0",
- "@eslint/core": "^0.15.0",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.31.0",
- "@eslint/plugin-kit": "^0.3.1",
+ "@eslint/js": "9.39.2",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
}
},
"node_modules/eslint-plugin-storybook": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.0.7.tgz",
- "integrity": "sha512-qOQq9KdT1jsBgT3qsxUH2n67aj1WR8D1XCoER8Q6yuVlS5TimNwk1mZeWkXVf/o4RQQT6flT2y5cG2gPLZPvJA==",
+ "version": "10.2.9",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.9.tgz",
+ "integrity": "sha512-nmPxjPw2KfmosqAUb/W0jmEfAZzK97kyJ8W5KMuweCblwjIL0hI/GMsWSP8CCBPnhQ9LnuxtT8JtQUOsslcbwA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/utils": "^8.8.1"
+ "@typescript-eslint/utils": "^8.48.0"
},
"peerDependencies": {
"eslint": ">=8",
- "storybook": "^10.0.7"
+ "storybook": "^10.2.9"
}
},
"node_modules/eslint-plugin-svelte": {
- "version": "3.11.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.11.0.tgz",
- "integrity": "sha512-KliWlkieHyEa65aQIkRwUFfHzT5Cn4u3BQQsu3KlkJOs7c1u7ryn84EWaOjEzilbKgttT4OfBURA8Uc4JBSQIw==",
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz",
+ "integrity": "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-load-config": "^3.1.4",
"postcss-safe-parser": "^7.0.0",
"semver": "^7.6.3",
- "svelte-eslint-parser": "^1.3.0"
+ "svelte-eslint-parser": "^1.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://github.com/sponsors/ota-meshi"
},
"peerDependencies": {
- "eslint": "^8.57.1 || ^9.0.0",
+ "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0",
"svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
- "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
}
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"node": ">= 0.4"
}
},
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "is-glob": "^4.0.3"
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
},
- "engines": {
- "node": ">=10.13.0"
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"dev": true,
"license": "ISC"
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"node": ">=12"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
"license": "MIT"
},
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.12.0"
}
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-wsl": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
+ "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"url": "https://opencollective.com/unified"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=8.6"
},
}
},
"node_modules/minizlib": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
- "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"node": ">= 18"
}
},
- "node_modules/mkdirp": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
- "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "mkdirp": "dist/cjs/src/bin.js"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/open": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
+ "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.2.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-inside-container": "^1.0.0",
+ "wsl-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"node": ">=8"
}
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"node": ">=6"
}
},
- "node_modules/prompts": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
- "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "kleur": "^3.0.3",
- "sisteransi": "^1.0.5"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/prompts/node_modules/kleur": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
- "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
}
},
"node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"node": ">=4"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rollup": {
"version": "4.45.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
"fsevents": "~2.3.2"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
"license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/runed": {
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
}
},
"node_modules/set-cookie-parser": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
- "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
+ "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==",
"dev": true,
"license": "MIT"
},
"dev": true,
"license": "ISC"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/sirv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
"node": ">=18"
}
},
- "node_modules/sisteransi": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
- "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"license": "MIT"
},
"node_modules/storybook": {
- "version": "10.0.7",
- "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.0.7.tgz",
- "integrity": "sha512-7smAu0o+kdm378Q2uIddk32pn0UdIbrtTVU+rXRVtTVTCrK/P2cCui2y4JH+Bl3NgEq1bbBQpCAF/HKrDjk2Qw==",
+ "version": "10.2.9",
+ "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.9.tgz",
+ "integrity": "sha512-DGok7XwIwdPWF+a49Yw+4madER5DZWRo9CdyySBLT3zeuxiEPt0Ua7ouJHm/y6ojnb/FVKZcQe8YmrE71s0qPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
- "@storybook/icons": "^1.6.0",
+ "@storybook/icons": "^2.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/user-event": "^14.6.1",
"@vitest/expect": "3.2.4",
- "@vitest/mocker": "3.2.4",
"@vitest/spy": "3.2.4",
- "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
+ "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0",
+ "open": "^10.2.0",
"recast": "^0.23.5",
- "semver": "^7.6.2",
+ "semver": "^7.7.3",
+ "use-sync-external-store": "^1.5.0",
"ws": "^8.18.0"
},
"bin": {
}
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
}
},
"node_modules/svelte-eslint-parser": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.0.tgz",
- "integrity": "sha512-VCgMHKV7UtOGcGLGNFSbmdm6kEKjtzo5nnpGU/mnx4OsFY6bZ7QwRF5DUx+Hokw5Lvdyo8dpk8B1m8mliomrNg==",
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz",
+ "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^7.0.0"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0",
+ "pnpm": "10.24.0"
},
"funding": {
"url": "https://github.com/sponsors/ota-meshi"
}
},
"node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
- "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
}
},
"node_modules/svelte2tsx": {
- "version": "0.7.45",
- "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.45.tgz",
- "integrity": "sha512-cSci+mYGygYBHIZLHlm/jYlEc1acjAHqaQaDFHdEBpUueM9kSTnPpvPtSl5VkJOU1qSJ7h1K+6F/LIUYiqC8VA==",
+ "version": "0.7.47",
+ "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.47.tgz",
+ "integrity": "sha512-1aw/MFKVPM96OBevJdC12do2an9t5Zwr3Va9amLgTLpJje36ibD1iIHpuqCYWUrdR9vw6g6btKGQPmsqE8ZYCw==",
"dev": true,
"license": "MIT",
"dependencies": {
}
},
"node_modules/tar": {
- "version": "7.4.3",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
- "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
+ "version": "7.5.9",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
+ "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
- "minizlib": "^3.0.1",
- "mkdirp": "^3.0.1",
+ "minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
+ "node_modules/test-exclude": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
}
},
"node_modules/typescript-eslint": {
- "version": "8.37.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz",
- "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==",
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
+ "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.37.0",
- "@typescript-eslint/parser": "8.37.0",
- "@typescript-eslint/typescript-estree": "8.37.0",
- "@typescript-eslint/utils": "8.37.0"
+ "@typescript-eslint/eslint-plugin": "8.56.0",
+ "@typescript-eslint/parser": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
}
},
"node_modules/unplugin": {
- "version": "2.3.10",
- "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz",
- "integrity": "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==",
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+ "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"dev": true,
"license": "MIT"
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
}
}
},
+ "node_modules/wsl-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+ "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"cleanup": "rm -rf .svelte-kit build node_modules test-results"
},
"devDependencies": {
- "@chromatic-com/storybook": "^4.1.2",
+ "@chromatic-com/storybook": "^5.0.0",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.515.0",
"@playwright/test": "^1.49.1",
- "@storybook/addon-a11y": "^10.0.7",
- "@storybook/addon-docs": "^10.0.7",
+ "@storybook/addon-a11y": "^10.2.4",
+ "@storybook/addon-docs": "^10.2.4",
"@storybook/addon-svelte-csf": "^5.0.10",
- "@storybook/addon-vitest": "^10.0.7",
- "@storybook/sveltekit": "^10.0.7",
+ "@storybook/addon-vitest": "^10.2.4",
+ "@storybook/sveltekit": "^10.2.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
- "@types/node": "^22",
+ "@types/node": "^24",
"@vitest/browser": "^3.2.3",
+ "@vitest/coverage-v8": "^3.2.3",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
- "eslint-plugin-storybook": "^10.0.7",
+ "eslint-plugin-storybook": "^10.2.4",
"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sass": "^1.93.3",
- "storybook": "^10.0.7",
+ "storybook": "^10.2.4",
"svelte": "^5.38.2",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
isImageFile,
isPdfFile,
isAudioFile,
- getLanguageFromFilename
+ getLanguageFromFilename,
+ createBase64DataUrl
} from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { modelsStore } from '$lib/stores/models.svelte';
<audio
controls
class="mb-4 w-full"
- src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
+ src={createBase64DataUrl(attachment.mimeType, attachment.base64Data)}
>
Your browser does not support the audio element.
</audio>
<script lang="ts">
- import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
+ import {
+ ChatAttachmentThumbnailImage,
+ ChatAttachmentThumbnailFile,
+ HorizontalScrollCarousel,
+ DialogChatAttachmentPreview,
+ DialogChatAttachmentsViewAll
+ } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
- import { ChevronLeft, ChevronRight } from '@lucide/svelte';
- import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
- let canScrollLeft = $state(false);
- let canScrollRight = $state(false);
+ let carouselRef: HorizontalScrollCarousel | undefined = $state();
let isScrollable = $state(false);
let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
- let scrollContainer: HTMLDivElement | undefined = $state();
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false);
previewDialogOpen = true;
}
- function scrollLeft(event?: MouseEvent) {
- event?.stopPropagation();
- event?.preventDefault();
-
- if (!scrollContainer) return;
-
- scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
- }
-
- function scrollRight(event?: MouseEvent) {
- event?.stopPropagation();
- event?.preventDefault();
-
- if (!scrollContainer) return;
-
- scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
- }
-
- function updateScrollButtons() {
- if (!scrollContainer) return;
-
- const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
-
- canScrollLeft = scrollLeft > 0;
- canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
- isScrollable = scrollWidth > clientWidth;
- }
-
$effect(() => {
- if (scrollContainer && displayItems.length) {
- scrollContainer.scrollLeft = 0;
-
- setTimeout(() => {
- updateScrollButtons();
- }, 0);
+ if (carouselRef && displayItems.length) {
+ carouselRef.resetScroll();
}
});
</script>
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
- <div class="relative">
- <button
- class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollLeft}
- aria-label="Scroll left"
- >
- <ChevronLeft class="h-4 w-4" />
- </button>
-
- <div
- class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
- bind:this={scrollContainer}
- onscroll={updateScrollButtons}
- >
- {#each displayItems as item (item.id)}
- {#if item.isImage && item.preview}
- <ChatAttachmentThumbnailImage
- class="flex-shrink-0 cursor-pointer {limitToSingleRow
- ? 'first:ml-4 last:mr-4'
- : ''}"
- id={item.id}
- name={item.name}
- preview={item.preview}
- {readonly}
- onRemove={onFileRemove}
- height={imageHeight}
- width={imageWidth}
- {imageClass}
- onClick={(event) => openPreview(item, event)}
- />
- {:else}
- <ChatAttachmentThumbnailFile
- class="flex-shrink-0 cursor-pointer {limitToSingleRow
- ? 'first:ml-4 last:mr-4'
- : ''}"
- id={item.id}
- name={item.name}
- size={item.size}
- {readonly}
- onRemove={onFileRemove}
- textContent={item.textContent}
- attachment={item.attachment}
- uploadedFile={item.uploadedFile}
- onClick={(event) => openPreview(item, event)}
- />
- {/if}
- {/each}
- </div>
-
- <button
- class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollRight}
- aria-label="Scroll right"
- >
- <ChevronRight class="h-4 w-4" />
- </button>
- </div>
+ <HorizontalScrollCarousel
+ bind:this={carouselRef}
+ onScrollableChange={(scrollable) => (isScrollable = scrollable)}
+ >
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentThumbnailImage
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {:else}
+ <ChatAttachmentThumbnailFile
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </HorizontalScrollCarousel>
{#if showViewAll}
<div class="mt-2 -mr-2 flex justify-end px-4">
<script lang="ts">
- import { afterNavigate } from '$app/navigation';
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
- ChatFormHelperText,
ChatFormTextarea
} from '$lib/components/app';
- import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
+ import { KeyboardKey, MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
- import { MimeTypeText } from '$lib/enums';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import {
AudioRecorder,
import { onMount } from 'svelte';
interface Props {
+ // Data
+ attachments?: DatabaseMessageExtra[];
+ uploadedFiles?: ChatUploadedFile[];
+ value?: string;
+
+ // UI State
class?: string;
disabled?: boolean;
- initialMessage?: string;
isLoading?: boolean;
- onFileRemove?: (fileId: string) => void;
- onFileUpload?: (files: File[]) => void;
- onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
+ placeholder?: string;
+
+ // Event Handlers
+ onAttachmentRemove?: (index: number) => void;
+ onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
- onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
- showHelperText?: boolean;
- uploadedFiles?: ChatUploadedFile[];
+ onSubmit?: () => void;
+ onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
+ onUploadedFileRemove?: (fileId: string) => void;
+ onValueChange?: (value: string) => void;
}
let {
- class: className,
+ attachments = [],
+ class: className = '',
disabled = false,
- initialMessage = '',
isLoading = false,
- onFileRemove,
- onFileUpload,
- onSend,
+ placeholder = 'Type a message...',
+ uploadedFiles = $bindable([]),
+ value = $bindable(''),
+ onAttachmentRemove,
+ onFilesAdd,
onStop,
- onSystemPromptAdd,
- showHelperText = true,
- uploadedFiles = $bindable([])
+ onSubmit,
+ onSystemPromptClick,
+ onUploadedFileRemove,
+ onValueChange
}: Props = $props();
+ /**
+ *
+ *
+ * STATE
+ *
+ *
+ */
+
+ // Component References
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
- let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
+ let textareaRef: ChatFormTextarea | undefined = $state(undefined);
+
+ // Audio Recording State
let isRecording = $state(false);
- let message = $derived(initialMessage);
+ let recordingSupported = $state(false);
+
+ /**
+ *
+ *
+ * DERIVED STATE
+ *
+ *
+ */
+
+ // Configuration
+ let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
- let previousIsLoading = $derived(isLoading);
- let previousInitialMessage = $derived(initialMessage);
- let recordingSupported = $state(false);
- let textareaRef: ChatFormTextarea | undefined = $state(undefined);
-
- // Sync message when initialMessage prop changes (e.g., after draft restoration)
- $effect(() => {
- if (initialMessage !== previousInitialMessage) {
- message = initialMessage;
- previousInitialMessage = initialMessage;
- }
- });
-
- function handleSystemPromptClick() {
- onSystemPromptAdd?.({ message, files: uploadedFiles });
- }
- // Check if model is selected (in ROUTER mode)
+ // Model Selection Logic
+ let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
- let isRouter = $derived(isRouterMode());
- let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
-
- // Get active model ID for capability detection
let activeModelId = $derived.by(() => {
const options = modelOptions();
return options.length > 0 ? options[0].model : null;
}
- // First try user-selected model
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
- // Fallback to conversation model
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
return null;
});
- function checkModelSelected(): boolean {
+ // Form Validation State
+ let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+ let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
+ let hasAttachments = $derived(
+ (attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
+ );
+ let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
+
+ /**
+ *
+ *
+ * LIFECYCLE
+ *
+ *
+ */
+
+ onMount(() => {
+ recordingSupported = isAudioRecordingSupported();
+ audioRecorder = new AudioRecorder();
+ });
+
+ /**
+ *
+ *
+ * PUBLIC API
+ *
+ *
+ */
+
+ export function focus() {
+ textareaRef?.focus();
+ }
+
+ export function resetTextareaHeight() {
+ textareaRef?.resetHeight();
+ }
+
+ export function openModelSelector() {
+ chatFormActionsRef?.openModelSelector();
+ }
+
+ /**
+ * Check if a model is selected, open selector if not
+ * @returns true if model is selected, false otherwise
+ */
+ export function checkModelSelected(): boolean {
if (!hasModelSelected) {
- // Open the model selector
chatFormActionsRef?.openModelSelector();
return false;
}
-
return true;
}
+ /**
+ *
+ *
+ * EVENT HANDLERS - File Management
+ *
+ *
+ */
+
function handleFileSelect(files: File[]) {
- onFileUpload?.(files);
+ onFilesAdd?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
- async function handleKeydown(event: KeyboardEvent) {
- if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
- event.preventDefault();
-
- if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
-
- if (!checkModelSelected()) return;
-
- const messageToSend = message.trim();
- const filesToSend = [...uploadedFiles];
+ 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);
+ }
+ } else {
+ onUploadedFileRemove?.(fileId);
+ }
+ }
- message = '';
- uploadedFiles = [];
+ /**
+ *
+ *
+ * EVENT HANDLERS - Input & Keyboard
+ *
+ *
+ */
- textareaRef?.resetHeight();
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
- const success = await onSend?.(messageToSend, filesToSend);
+ if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
- if (!success) {
- message = messageToSend;
- uploadedFiles = filesToSend;
- }
+ onSubmit?.();
}
}
if (files.length > 0) {
event.preventDefault();
- onFileUpload?.(files);
-
+ onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
- if (text.startsWith('"')) {
+ if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
-
- message = parsed.message;
-
- const attachmentFiles = parsed.textAttachments.map(
- (att) =>
- new File([att.content], att.name, {
- type: MimeTypeText.PLAIN
- })
- );
-
- onFileUpload?.(attachmentFiles);
+ value = parsed.message;
+ onValueChange?.(parsed.message);
+
+ // Handle text attachments as files
+ if (parsed.textAttachments.length > 0) {
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+ onFilesAdd?.(attachmentFiles);
+ }
setTimeout(() => {
textareaRef?.focus();
type: MimeTypeText.PLAIN
});
- onFileUpload?.([textFile]);
+ onFilesAdd?.([textFile]);
}
}
+ /**
+ *
+ *
+ * EVENT HANDLERS - Audio Recording
+ *
+ *
+ */
+
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
-
return;
}
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
- onFileUpload?.([audioFile]);
+ onFilesAdd?.([audioFile]);
isRecording = false;
} catch (error) {
console.error('Failed to stop recording:', error);
}
}
}
-
- function handleStop() {
- onStop?.();
- }
-
- async function handleSubmit(event: SubmitEvent) {
- event.preventDefault();
- if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
-
- // Check if model is selected first
- if (!checkModelSelected()) return;
-
- const messageToSend = message.trim();
- const filesToSend = [...uploadedFiles];
-
- message = '';
- uploadedFiles = [];
-
- textareaRef?.resetHeight();
-
- const success = await onSend?.(messageToSend, filesToSend);
-
- if (!success) {
- message = messageToSend;
- uploadedFiles = filesToSend;
- }
- }
-
- onMount(() => {
- setTimeout(() => textareaRef?.focus(), 10);
- recordingSupported = isAudioRecordingSupported();
- audioRecorder = new AudioRecorder();
- });
-
- afterNavigate(() => {
- setTimeout(() => textareaRef?.focus(), 10);
- });
-
- $effect(() => {
- if (previousIsLoading && !isLoading) {
- setTimeout(() => textareaRef?.focus(), 10);
- }
-
- previousIsLoading = isLoading;
- });
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
- onsubmit={handleSubmit}
- class="relative {INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
- ? 'cursor-not-allowed opacity-60'
- : ''} {className}"
- data-slot="chat-form"
+ class="relative {className}"
+ onsubmit={(e) => {
+ e.preventDefault();
+ if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
+ onSubmit?.();
+ }}
>
- <ChatAttachmentsList
- bind:uploadedFiles
- {onFileRemove}
- limitToSingleRow
- class="py-5"
- style="scroll-padding: 1rem;"
- activeModelId={activeModelId ?? undefined}
- />
-
<div
- class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
- onpaste={handlePaste}
+ class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
+ ? 'cursor-not-allowed opacity-60'
+ : ''}"
+ data-slot="input-area"
>
- <ChatFormTextarea
- class="px-5 py-1.5 md:pt-0"
- bind:this={textareaRef}
- bind:value={message}
- onKeydown={handleKeydown}
- {disabled}
+ <ChatAttachmentsList
+ {attachments}
+ bind:uploadedFiles
+ onFileRemove={handleFileRemove}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ activeModelId={activeModelId ?? undefined}
/>
- <ChatFormActions
- class="px-3"
- bind:this={chatFormActionsRef}
- canSend={message.trim().length > 0 || uploadedFiles.length > 0}
- hasText={message.trim().length > 0}
- {disabled}
- {isLoading}
- {isRecording}
- {uploadedFiles}
- onFileUpload={handleFileUpload}
- onMicClick={handleMicClick}
- onStop={handleStop}
- onSystemPromptClick={handleSystemPromptClick}
- />
+ <div
+ class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
+ onpaste={handlePaste}
+ >
+ <ChatFormTextarea
+ class="px-5 py-1.5 md:pt-0"
+ bind:this={textareaRef}
+ bind:value
+ onKeydown={handleKeydown}
+ onInput={() => {
+ onValueChange?.(value);
+ }}
+ {disabled}
+ {placeholder}
+ />
+
+ <ChatFormActions
+ class="px-3"
+ bind:this={chatFormActionsRef}
+ canSend={canSubmit}
+ hasText={value.trim().length > 0}
+ {disabled}
+ {isLoading}
+ {isRecording}
+ {uploadedFiles}
+ onFileUpload={handleFileUpload}
+ onMicClick={handleMicClick}
+ {onStop}
+ onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
+ />
+ </div>
</div>
</form>
-
-<ChatFormHelperText show={showHelperText} />
<script lang="ts">
import { page } from '$app/state';
- import { MessageSquare, Plus } from '@lucide/svelte';
+ import { Plus, MessageSquare } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
onSystemPromptClick?: () => void;
}
- type AttachmentActionId = 'images' | 'audio' | 'text' | 'pdf' | 'system';
-
- interface AttachmentAction {
- id: AttachmentActionId;
- label: string;
- disabled?: boolean;
- disabledReason?: string;
- tooltip?: string;
- }
-
let {
class: className = '',
disabled = false,
}: Props = $props();
let isNewChat = $derived(!page.params.id);
+
let systemMessageTooltip = $derived(
isNewChat
? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation'
);
- let actions = $derived.by<AttachmentAction[]>(() => [
- {
- id: 'images',
- label: 'Images',
- disabled: !hasVisionModality,
- disabledReason: !hasVisionModality
- ? 'Images require vision models to be processed'
- : undefined
- },
- {
- id: 'audio',
- label: 'Audio Files',
- disabled: !hasAudioModality,
- disabledReason: !hasAudioModality
- ? 'Audio files require audio models to be processed'
- : undefined
- },
- {
- id: 'text',
- label: 'Text Files'
- },
- {
- id: 'pdf',
- label: 'PDF Files',
- tooltip: !hasVisionModality
- ? 'PDFs will be converted to text. Image-based PDFs may not work properly.'
- : undefined
- },
- {
- id: 'system',
- label: 'System Message',
- tooltip: systemMessageTooltip
- }
- ]);
-
- function handleActionClick(id: AttachmentActionId) {
- if (id === 'system') {
- onSystemPromptClick?.();
- return;
- }
-
- onFileUpload?.();
- }
+ let dropdownOpen = $state(false);
- const triggerTooltipText = 'Add files or system message';
- const itemClass = 'flex cursor-pointer items-center gap-2';
+ const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script>
<div class="flex items-center gap-1 {className}">
- <DropdownMenu.Root>
+ <DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
variant="secondary"
type="button"
>
- <span class="sr-only">{triggerTooltipText}</span>
+ <span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
- <p>{triggerTooltipText}</p>
+ <p>{fileUploadTooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Trigger>
- <DropdownMenu.Content align="start" class="w-56">
- {#each actions as item (item.id)}
- {@const hasDisabledTooltip = !!item.disabled && !!item.disabledReason}
- {@const hasEnabledTooltip = !item.disabled && !!item.tooltip}
-
- {#if hasDisabledTooltip}
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item class={itemClass} disabled>
- {#if item.id === 'images'}
- <FILE_TYPE_ICONS.image class="h-4 w-4" />
- {:else if item.id === 'audio'}
- <FILE_TYPE_ICONS.audio class="h-4 w-4" />
- {:else if item.id === 'text'}
- <FILE_TYPE_ICONS.text class="h-4 w-4" />
- {:else if item.id === 'pdf'}
- <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
- {:else}
- <MessageSquare class="h-4 w-4" />
- {/if}
-
- <span>{item.label}</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- <Tooltip.Content side="right">
- <p>{item.disabledReason}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {:else if hasEnabledTooltip}
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
- {#if item.id === 'images'}
- <FILE_TYPE_ICONS.image class="h-4 w-4" />
- {:else if item.id === 'audio'}
- <FILE_TYPE_ICONS.audio class="h-4 w-4" />
- {:else if item.id === 'text'}
- <FILE_TYPE_ICONS.text class="h-4 w-4" />
- {:else if item.id === 'pdf'}
- <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
- {:else}
- <MessageSquare class="h-4 w-4" />
- {/if}
-
- <span>{item.label}</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- <Tooltip.Content side="right">
- <p>{item.tooltip}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {:else}
- <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
- {#if item.id === 'images'}
+ <DropdownMenu.Content align="start" class="w-48">
+ {#if hasVisionModality}
+ <DropdownMenu.Item
+ class="images-button flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+
+ <span>Images</span>
+ </DropdownMenu.Item>
+ {:else}
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="images-button flex cursor-pointer items-center gap-2"
+ disabled
+ >
<FILE_TYPE_ICONS.image class="h-4 w-4" />
- {:else if item.id === 'audio'}
+
+ <span>Images</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>Images require vision models to be processed</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ {#if hasAudioModality}
+ <DropdownMenu.Item
+ class="audio-button flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+
+ <span>Audio Files</span>
+ </DropdownMenu.Item>
+ {:else}
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item class="audio-button flex cursor-pointer items-center gap-2" disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
- {:else if item.id === 'text'}
- <FILE_TYPE_ICONS.text class="h-4 w-4" />
- {:else if item.id === 'pdf'}
+
+ <span>Audio Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>Audio files require audio models to be processed</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+
+ <span>Text Files</span>
+ </DropdownMenu.Item>
+
+ {#if hasVisionModality}
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+
+ <span>PDF Files</span>
+ </DropdownMenu.Item>
+ {:else}
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
- {:else}
- <MessageSquare class="h-4 w-4" />
- {/if}
- <span>{item.label}</span>
+ <span>PDF Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onSystemPromptClick?.()}
+ >
+ <MessageSquare class="h-4 w-4" />
+
+ <span>System Message</span>
</DropdownMenu.Item>
- {/if}
- {/each}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>{systemMessageTooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
+++ /dev/null
-<script lang="ts">
- import { Paperclip } from '@lucide/svelte';
- import { MessageSquare } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { FILE_TYPE_ICONS } from '$lib/constants/icons';
-
- interface Props {
- class?: string;
- disabled?: boolean;
- hasAudioModality?: boolean;
- hasVisionModality?: boolean;
- onFileUpload?: () => void;
- onSystemPromptClick?: () => void;
- }
-
- let {
- class: className = '',
- disabled = false,
- hasAudioModality = false,
- hasVisionModality = false,
- onFileUpload,
- onSystemPromptClick
- }: Props = $props();
-
- const fileUploadTooltipText = $derived.by(() => {
- return !hasVisionModality
- ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
- : 'Attach files';
- });
-</script>
-
-<div class="flex items-center gap-1 {className}">
- <DropdownMenu.Root>
- <DropdownMenu.Trigger name="Attach files" {disabled}>
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Button
- class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
- {disabled}
- type="button"
- >
- <span class="sr-only">Attach files</span>
-
- <Paperclip class="h-4 w-4" />
- </Button>
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p>{fileUploadTooltipText}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- </DropdownMenu.Trigger>
-
- <DropdownMenu.Content align="start" class="w-48">
- <Tooltip.Root>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="images-button flex cursor-pointer items-center gap-2"
- disabled={!hasVisionModality}
- onclick={() => onFileUpload?.()}
- >
- <FILE_TYPE_ICONS.image class="h-4 w-4" />
-
- <span>Images</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !hasVisionModality}
- <Tooltip.Content>
- <p>Images require vision models to be processed</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-
- <Tooltip.Root>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="audio-button flex cursor-pointer items-center gap-2"
- disabled={!hasAudioModality}
- onclick={() => onFileUpload?.()}
- >
- <FILE_TYPE_ICONS.audio class="h-4 w-4" />
-
- <span>Audio Files</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !hasAudioModality}
- <Tooltip.Content>
- <p>Audio files require audio models to be processed</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={() => onFileUpload?.()}
- >
- <FILE_TYPE_ICONS.text class="h-4 w-4" />
-
- <span>Text Files</span>
- </DropdownMenu.Item>
-
- <Tooltip.Root>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={() => onFileUpload?.()}
- >
- <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
-
- <span>PDF Files</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !hasVisionModality}
- <Tooltip.Content>
- <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
- <DropdownMenu.Separator />
- <Tooltip.Root>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={() => onSystemPromptClick?.()}
- >
- <MessageSquare class="h-4 w-4" />
-
- <span>System Prompt</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p>Add a custom system message for this conversation</p>
- </Tooltip.Content>
- </Tooltip.Root>
- </DropdownMenu.Content>
- </DropdownMenu.Root>
-</div>
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
- import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
- import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
canSend?: boolean;
export function openModelSelector() {
selectorModelRef?.open();
}
-
- const { handleModelChange } = useModelChangeValidation({
- getRequiredModalities: () => usedModalities(),
- onValidationFailure: async (previousModelId: string | null) => {
- if (previousModelId) {
- await modelsStore.selectModelById(previousModelId);
- }
- }
- });
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
currentModel={conversationModel}
forceForegroundText={true}
useGlobalSelection={true}
- onModelChange={handleModelChange}
/>
</div>
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
- import {
- chatStore,
- pendingEditMessageId,
- clearPendingEditMessageId,
- removeSystemPromptPlaceholder
- } from '$lib/stores/chat.svelte';
+ import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
+ import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services';
- import { config } from '$lib/stores/settings.svelte';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
- import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
- import ChatMessageAssistant from './ChatMessageAssistant.svelte';
- import ChatMessageUser from './ChatMessageUser.svelte';
- import ChatMessageSystem from './ChatMessageSystem.svelte';
+ import { MessageRole } from '$lib/enums';
+ import {
+ ChatMessageAssistant,
+ ChatMessageUser,
+ ChatMessageSystem
+ } from '$lib/components/app/chat';
+ import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props {
class?: string;
message: DatabaseMessage;
- onCopy?: (message: DatabaseMessage) => void;
- onContinueAssistantMessage?: (message: DatabaseMessage) => void;
- onDelete?: (message: DatabaseMessage) => void;
- onEditWithBranching?: (
- message: DatabaseMessage,
- newContent: string,
- newExtras?: DatabaseMessageExtra[]
- ) => void;
- onEditWithReplacement?: (
- message: DatabaseMessage,
- newContent: string,
- shouldBranch: boolean
- ) => void;
- onEditUserMessagePreserveResponses?: (
- message: DatabaseMessage,
- newContent: string,
- newExtras?: DatabaseMessageExtra[]
- ) => void;
- onNavigateToSibling?: (siblingId: string) => void;
- onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
+ isLastAssistantMessage?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null;
}
let {
class: className = '',
message,
- onCopy,
- onContinueAssistantMessage,
- onDelete,
- onEditWithBranching,
- onEditWithReplacement,
- onEditUserMessagePreserveResponses,
- onNavigateToSibling,
- onRegenerateWithBranching,
+ isLastAssistantMessage = false,
siblingInfo = null
}: Props = $props();
+ const chatActions = getChatActionsContext();
+
let deletionInfo = $state<{
totalCount: number;
userMessages: number;
let shouldBranchAfterEdit = $state(false);
let textareaElement: HTMLTextAreaElement | undefined = $state();
- let thinkingContent = $derived.by(() => {
- if (message.role === 'assistant') {
- const trimmedThinking = message.thinking?.trim();
-
- return trimmedThinking ? trimmedThinking : null;
- }
- return null;
- });
-
- let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
- if (message.role === 'assistant') {
- const trimmedToolCalls = message.toolCalls?.trim();
+ let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
- if (!trimmedToolCalls) {
- return null;
- }
-
- try {
- const parsed = JSON.parse(trimmedToolCalls);
-
- if (Array.isArray(parsed)) {
- return parsed as ApiChatCompletionToolCall[];
- }
- } catch {
- // Harmony-only path: fall back to the raw string so issues surface visibly.
- }
-
- return trimmedToolCalls;
- }
- return null;
+ setMessageEditContext({
+ get isEditing() {
+ return isEditing;
+ },
+ get editedContent() {
+ return editedContent;
+ },
+ get editedExtras() {
+ return editedExtras;
+ },
+ get editedUploadedFiles() {
+ return editedUploadedFiles;
+ },
+ get originalContent() {
+ return message.content;
+ },
+ get originalExtras() {
+ return message.extra || [];
+ },
+ get showSaveOnlyOption() {
+ return showSaveOnlyOption;
+ },
+ setContent: (content: string) => {
+ editedContent = content;
+ },
+ setExtras: (extras: DatabaseMessageExtra[]) => {
+ editedExtras = extras;
+ },
+ setUploadedFiles: (files: ChatUploadedFile[]) => {
+ editedUploadedFiles = files;
+ },
+ save: handleSaveEdit,
+ saveOnly: handleSaveEditOnly,
+ cancel: handleCancelEdit,
+ startEdit: handleEdit
});
- // Auto-start edit mode if this message is the pending edit target
$effect(() => {
const pendingId = pendingEditMessageId();
if (pendingId && pendingId === message.id && !isEditing) {
handleEdit();
- clearPendingEditMessageId();
+ chatStore.clearPendingEditMessageId();
}
});
isEditing = false;
// If canceling a new system message with placeholder content, remove it without deleting children
- if (message.role === 'system') {
- const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+ if (message.role === MessageRole.SYSTEM) {
+ const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(`${base}/`);
editedUploadedFiles = [];
}
- function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
- editedExtras = extras;
- }
-
- function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
- editedUploadedFiles = files;
- }
-
- async function handleCopy() {
- const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
- const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
- await copyToClipboard(clipboardContent, 'Message copied to clipboard');
- onCopy?.(message);
+ function handleCopy() {
+ chatActions.copy(message);
}
async function handleConfirmDelete() {
- if (message.role === 'system') {
- const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+ if (message.role === MessageRole.SYSTEM) {
+ const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
- goto('/');
+ goto(`${base}/`);
}
} else {
- onDelete?.(message);
+ chatActions.delete(message);
}
showDeleteDialog = false;
function handleEdit() {
isEditing = true;
- // Clear placeholder content for system messages
+ // Clear temporary placeholder content for system messages
editedContent =
- message.role === 'system' && message.content === SYSTEM_MESSAGE_PLACEHOLDER
+ message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
? ''
: message.content;
textareaElement?.focus();
}, 0);
}
- function handleEditedContentChange(content: string) {
- editedContent = content;
- }
-
- function handleEditKeydown(event: KeyboardEvent) {
- // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
- // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
- if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
- event.preventDefault();
- handleSaveEdit();
- } else if (event.key === 'Escape') {
- event.preventDefault();
- handleCancelEdit();
- }
- }
-
function handleRegenerate(modelOverride?: string) {
- onRegenerateWithBranching?.(message, modelOverride);
+ chatActions.regenerateWithBranching(message, modelOverride);
}
function handleContinue() {
- onContinueAssistantMessage?.(message);
+ chatActions.continueAssistantMessage(message);
+ }
+
+ function handleNavigateToSibling(siblingId: string) {
+ chatActions.navigateToSibling(siblingId);
}
async function handleSaveEdit() {
- if (message.role === 'system') {
+ if (message.role === MessageRole.SYSTEM) {
// System messages: update in place without branching
const newContent = editedContent.trim();
- // If content is empty or still the placeholder, remove without deleting children
+ // If content is empty, remove without deleting children
if (!newContent) {
- const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+ const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
goto(`${base}/`);
if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent });
}
- } else if (message.role === 'user') {
+ } else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras();
- onEditWithBranching?.(message, editedContent.trim(), finalExtras);
+ chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else {
// For assistant messages, preserve exact content including trailing whitespace
// This is important for the Continue feature to work properly
- onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
+ chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
}
isEditing = false;
}
async function handleSaveEditOnly() {
- if (message.role === 'user') {
+ if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras();
- onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
+ chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
}
isEditing = false;
return editedExtras;
}
- const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
- const result = await parseFilesToMessageExtras(editedUploadedFiles);
+ const plainFiles = $state.snapshot(editedUploadedFiles);
+ const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || [];
return [...editedExtras, ...newExtras];
}
</script>
-{#if message.role === 'system'}
+{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
class={className}
{deletionInfo}
- {editedContent}
- {isEditing}
{message}
- onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
- onEditKeydown={handleEditKeydown}
- onEditedContentChange={handleEditedContentChange}
- {onNavigateToSibling}
- onSaveEdit={handleSaveEdit}
+ onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
-{:else if message.role === 'user'}
+{:else if message.role === MessageRole.USER}
<ChatMessageUser
- bind:textareaElement
class={className}
{deletionInfo}
- {editedContent}
- {editedExtras}
- {editedUploadedFiles}
- {isEditing}
{message}
- onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
- onEditKeydown={handleEditKeydown}
- onEditedContentChange={handleEditedContentChange}
- onEditedExtrasChange={handleEditedExtrasChange}
- onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
- {onNavigateToSibling}
- onSaveEdit={handleSaveEdit}
- onSaveEditOnly={handleSaveEditOnly}
+ onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
bind:textareaElement
class={className}
{deletionInfo}
- {editedContent}
- {isEditing}
+ {isLastAssistantMessage}
{message}
messageContent={message.content}
- onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
- onEditKeydown={handleEditKeydown}
- onEditedContentChange={handleEditedContentChange}
- {onNavigateToSibling}
+ onNavigateToSibling={handleNavigateToSibling}
onRegenerate={handleRegenerate}
- onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
- {shouldBranchAfterEdit}
- onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
{showDeleteDialog}
{siblingInfo}
- {thinkingContent}
- {toolCallContent}
/>
{/if}
<script lang="ts">
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
import {
- ActionButton,
+ ActionIcon,
ChatMessageBranchingControls,
DialogConfirmation
} from '$lib/components/app';
import { Switch } from '$lib/components/ui/switch';
+ import { MessageRole } from '$lib/enums';
interface Props {
- role: 'user' | 'assistant';
+ role: MessageRole.USER | MessageRole.ASSISTANT;
justify: 'start' | 'end';
actionsPosition: 'left' | 'right';
siblingInfo?: ChatMessageSiblingInfo | null;
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
>
- <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
+ <ActionIcon icon={Copy} tooltip="Copy" onclick={onCopy} />
{#if onEdit}
- <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
+ <ActionIcon icon={Edit} tooltip="Edit" onclick={onEdit} />
{/if}
- {#if role === 'assistant' && onRegenerate}
- <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
+ {#if role === MessageRole.ASSISTANT && onRegenerate}
+ <ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
{/if}
- {#if role === 'assistant' && onContinue}
- <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+ {#if role === MessageRole.ASSISTANT && onContinue}
+ <ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
{/if}
- <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
+ <ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
</div>
</div>
<script lang="ts">
import {
- ModelBadge,
ChatMessageActions,
ChatMessageStatistics,
- ChatMessageThinkingBlock,
- CopyToClipboardIcon,
MarkdownContent,
+ ModelBadge,
ModelsSelector
} from '$lib/components/app';
+ import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
+ import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
- import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
- import { isLoading } from '$lib/stores/chat.svelte';
- import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
+ import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
+ import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
+ import { tick } from 'svelte';
import { fade } from 'svelte/transition';
- import { Check, X, Wrench } from '@lucide/svelte';
+ import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
- import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { INPUT_CLASSES } from '$lib/constants/css-classes';
+ import { MessageRole, KeyboardKey } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { ServerModelStatus } from '$lib/enums';
+ import { REASONING_TAGS } from '$lib/constants/agentic';
interface Props {
class?: string;
assistantMessages: number;
messageTypes: string[];
} | null;
- editedContent?: string;
- isEditing?: boolean;
+ isLastAssistantMessage?: boolean;
message: DatabaseMessage;
messageContent: string | undefined;
- onCancelEdit?: () => void;
onCopy: () => void;
onConfirmDelete: () => void;
onContinue?: () => void;
onDelete: () => void;
onEdit?: () => void;
- onEditKeydown?: (event: KeyboardEvent) => void;
- onEditedContentChange?: (content: string) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerate: (modelOverride?: string) => void;
- onSaveEdit?: () => void;
onShowDeleteDialogChange: (show: boolean) => void;
- onShouldBranchAfterEditChange?: (value: boolean) => void;
showDeleteDialog: boolean;
- shouldBranchAfterEdit?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null;
textareaElement?: HTMLTextAreaElement;
- thinkingContent: string | null;
- toolCallContent: ApiChatCompletionToolCall[] | string | null;
+ }
+
+ interface ParsedReasoningContent {
+ content: string;
+ reasoningContent: string | null;
+ hasReasoningMarkers: boolean;
+ }
+
+ function parseReasoningContent(content: string | undefined): ParsedReasoningContent {
+ if (!content) {
+ return {
+ content: '',
+ reasoningContent: null,
+ hasReasoningMarkers: false
+ };
+ }
+
+ const plainParts: string[] = [];
+ const reasoningParts: string[] = [];
+ const { START, END } = REASONING_TAGS;
+ let cursor = 0;
+ let hasReasoningMarkers = false;
+
+ while (cursor < content.length) {
+ const startIndex = content.indexOf(START, cursor);
+
+ if (startIndex === -1) {
+ plainParts.push(content.slice(cursor));
+ break;
+ }
+
+ hasReasoningMarkers = true;
+ plainParts.push(content.slice(cursor, startIndex));
+
+ const reasoningStart = startIndex + START.length;
+ const endIndex = content.indexOf(END, reasoningStart);
+
+ if (endIndex === -1) {
+ reasoningParts.push(content.slice(reasoningStart));
+ cursor = content.length;
+ break;
+ }
+
+ reasoningParts.push(content.slice(reasoningStart, endIndex));
+ cursor = endIndex + END.length;
+ }
+
+ return {
+ content: plainParts.join(''),
+ reasoningContent: reasoningParts.length > 0 ? reasoningParts.join('\n\n') : null,
+ hasReasoningMarkers
+ };
}
let {
class: className = '',
deletionInfo,
- editedContent = '',
- isEditing = false,
+ isLastAssistantMessage = false,
message,
messageContent,
- onCancelEdit,
onConfirmDelete,
onContinue,
onCopy,
onDelete,
onEdit,
- onEditKeydown,
- onEditedContentChange,
onNavigateToSibling,
onRegenerate,
- onSaveEdit,
onShowDeleteDialogChange,
- onShouldBranchAfterEditChange,
showDeleteDialog,
- shouldBranchAfterEdit = false,
siblingInfo = null,
- textareaElement = $bindable(),
- thinkingContent,
- toolCallContent = null
+ textareaElement = $bindable()
}: Props = $props();
- const toolCalls = $derived(
- Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
- );
- const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
+ // Get edit context
+ const editCtx = getMessageEditContext();
- const processingState = useProcessingState();
+ // Local state for assistant-specific editing
+ let shouldBranchAfterEdit = $state(false);
- // Local state for raw output toggle (per message)
- let showRawOutput = $state(false);
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+ editCtx.save();
+ } else if (event.key === KeyboardKey.ESCAPE) {
+ event.preventDefault();
+ editCtx.cancel();
+ }
+ }
+
+ const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent));
+ const visibleMessageContent = $derived(parsedMessageContent.content);
+ const thinkingContent = $derived(parsedMessageContent.reasoningContent);
+ const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers);
+ const processingState = useProcessingState();
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
- let displayedModel = $derived((): string | null => {
- if (message.model) {
- return message.model;
+ let showRawOutput = $state(false);
+ let statsContainerEl: HTMLDivElement | undefined = $state();
+
+ function getScrollParent(el: HTMLElement): HTMLElement | null {
+ let parent = el.parentElement;
+ while (parent) {
+ const style = getComputedStyle(parent);
+ if (/(auto|scroll)/.test(style.overflowY)) {
+ return parent;
+ }
+ parent = parent.parentElement;
}
-
return null;
- });
-
- const { handleModelChange } = useModelChangeValidation({
- getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
- onSuccess: (modelName: string) => onRegenerate(modelName)
- });
-
- function handleCopyModel() {
- const model = displayedModel();
-
- void copyToClipboard(model ?? '');
}
- $effect(() => {
- if (isEditing && textareaElement) {
- autoResizeTextarea(textareaElement);
+ async function handleStatsViewChange() {
+ const el = statsContainerEl;
+ if (!el) {
+ return;
}
- });
- $effect(() => {
- if (isLoading() && !message?.content?.trim()) {
- processingState.startMonitoring();
+ const scrollParent = getScrollParent(el);
+ if (!scrollParent) {
+ return;
}
- });
-
- function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
- const callNumber = index + 1;
- const functionName = toolCall.function?.name?.trim();
- const label = functionName || `Call #${callNumber}`;
- const payload: Record<string, unknown> = {};
+ const yBefore = el.getBoundingClientRect().top;
- const id = toolCall.id?.trim();
- if (id) {
- payload.id = id;
- }
+ await tick();
- const type = toolCall.type?.trim();
- if (type) {
- payload.type = type;
+ const delta = el.getBoundingClientRect().top - yBefore;
+ if (delta !== 0) {
+ scrollParent.scrollTop += delta;
}
- if (toolCall.function) {
- const fnPayload: Record<string, unknown> = {};
+ // Correct any drift after browser paint
+ requestAnimationFrame(() => {
+ const drift = el.getBoundingClientRect().top - yBefore;
- const name = toolCall.function.name?.trim();
- if (name) {
- fnPayload.name = name;
+ if (Math.abs(drift) > 1) {
+ scrollParent.scrollTop += drift;
}
+ });
+ }
- const rawArguments = toolCall.function.arguments?.trim();
- if (rawArguments) {
- try {
- fnPayload.arguments = JSON.parse(rawArguments);
- } catch {
- fnPayload.arguments = rawArguments;
- }
- }
+ let displayedModel = $derived(message.model ?? null);
- if (Object.keys(fnPayload).length > 0) {
- payload.function = fnPayload;
- }
- }
+ let isCurrentlyLoading = $derived(isLoading());
+ let isStreaming = $derived(isChatStreaming());
+ let hasNoContent = $derived(!visibleMessageContent?.trim());
+ let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
- const formattedPayload = JSON.stringify(payload, null, 2);
+ let showProcessingInfoTop = $derived(
+ message?.role === MessageRole.ASSISTANT &&
+ isActivelyProcessing &&
+ hasNoContent &&
+ isLastAssistantMessage
+ );
- return {
- label,
- tooltip: formattedPayload,
- copyValue: formattedPayload
- };
- }
+ let showProcessingInfoBottom = $derived(
+ message?.role === MessageRole.ASSISTANT &&
+ isActivelyProcessing &&
+ !hasNoContent &&
+ isLastAssistantMessage
+ );
- function handleCopyToolCall(payload: string) {
- void copyToClipboard(payload, 'Tool call copied to clipboard');
+ function handleCopyModel() {
+ void copyToClipboard(displayedModel ?? '');
}
+
+ $effect(() => {
+ if (editCtx.isEditing && textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ if (showProcessingInfoTop || showProcessingInfoBottom) {
+ processingState.startMonitoring();
+ }
+ });
</script>
<div
role="group"
aria-label="Assistant message with actions"
>
- {#if thinkingContent}
+ {#if !editCtx.isEditing && thinkingContent}
<ChatMessageThinkingBlock
reasoningContent={thinkingContent}
isStreaming={!message.timestamp}
- hasRegularContent={!!messageContent?.trim()}
+ hasRegularContent={!!visibleMessageContent?.trim()}
/>
{/if}
- {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
+ {#if showProcessingInfoTop}
<div class="mt-6 w-full max-w-[48rem]" in:fade>
<div class="processing-container">
<span class="processing-text">
- {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
+ {processingState.getPromptProgressText() ??
+ processingState.getProcessingMessage() ??
+ 'Processing...'}
</span>
</div>
</div>
{/if}
- {#if isEditing}
+ {#if editCtx.isEditing}
<div class="w-full">
<textarea
bind:this={textareaElement}
- bind:value={editedContent}
+ value={editCtx.editedContent}
class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
- onkeydown={onEditKeydown}
+ onkeydown={handleEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
- onEditedContentChange?.(e.currentTarget.value);
+ editCtx.setContent(e.currentTarget.value);
}}
placeholder="Edit assistant message..."
></textarea>
<Checkbox
id="branch-after-edit"
bind:checked={shouldBranchAfterEdit}
- onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
+ onCheckedChange={(checked) => (shouldBranchAfterEdit = checked === true)}
/>
<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
Branch conversation after edit
</Label>
</div>
<div class="flex gap-2">
- <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
Cancel
</Button>
- <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
+ <Button
+ class="h-8 px-3"
+ onclick={editCtx.save}
+ disabled={!editCtx.editedContent?.trim()}
+ size="sm"
+ >
<Check class="mr-1 h-3 w-3" />
Save
</Button>
</div>
</div>
</div>
- {:else if message.role === 'assistant'}
+ {:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre>
{:else}
- <MarkdownContent content={messageContent || ''} />
+ <MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
{/if}
{:else}
<div class="text-sm whitespace-pre-wrap">
</div>
{/if}
+ {#if showProcessingInfoBottom}
+ <div class="mt-4 w-full max-w-[48rem]" in:fade>
+ <div class="processing-container">
+ <span class="processing-text">
+ {processingState.getPromptProgressText() ??
+ processingState.getProcessingMessage() ??
+ 'Processing...'}
+ </span>
+ </div>
+ </div>
+ {/if}
+
<div class="info my-6 grid gap-4 tabular-nums">
- {#if displayedModel()}
- <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
+ {#if displayedModel}
+ <div
+ bind:this={statsContainerEl}
+ class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
+ >
{#if isRouter}
<ModelsSelector
- currentModel={displayedModel()}
- onModelChange={handleModelChange}
+ currentModel={displayedModel}
disabled={isLoading()}
- upToMessageId={message.id}
+ onModelChange={async (modelId, modelName) => {
+ const status = modelsStore.getModelStatus(modelId);
+
+ if (status !== ServerModelStatus.LOADED) {
+ await modelsStore.loadModel(modelId);
+ }
+
+ onRegenerate(modelName);
+ return true;
+ }}
/>
{:else}
- <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
+ <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
{/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms}
+ onActiveViewChange={handleStatsViewChange}
/>
{:else if isLoading() && currentConfig.showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}
{/if}
</div>
{/if}
-
- {#if config().showToolCalls}
- {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
- <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
- <span class="inline-flex items-center gap-1">
- <Wrench class="h-3.5 w-3.5" />
-
- <span>Tool calls:</span>
- </span>
-
- {#if toolCalls && toolCalls.length > 0}
- {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
- {@const badge = formatToolCallBadge(toolCall, index)}
- <button
- type="button"
- class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
- title={badge.tooltip}
- aria-label={`Copy tool call ${badge.label}`}
- onclick={() => handleCopyToolCall(badge.copyValue)}
- >
- {badge.label}
- <CopyToClipboardIcon
- text={badge.copyValue}
- ariaLabel={`Copy tool call ${badge.label}`}
- />
- </button>
- {/each}
- {:else if fallbackToolCalls}
- <button
- type="button"
- class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
- title={fallbackToolCalls}
- aria-label="Copy tool call payload"
- onclick={() => handleCopyToolCall(fallbackToolCalls)}
- >
- {fallbackToolCalls}
- <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
- </button>
- {/if}
- </span>
- {/if}
- {/if}
</div>
- {#if message.timestamp && !isEditing}
+ {#if message.timestamp && !editCtx.isEditing}
<ChatMessageActions
- role="assistant"
+ role={MessageRole.ASSISTANT}
justify="start"
actionsPosition="left"
{siblingInfo}
{onCopy}
{onEdit}
{onRegenerate}
- onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+ onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
? onContinue
: undefined}
{onDelete}
white-space: pre-wrap;
word-break: break-word;
}
-
- .tool-call-badge {
- max-width: 12rem;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .tool-call-badge--fallback {
- max-width: 20rem;
- white-space: normal;
- word-break: break-word;
- }
</style>
<script lang="ts">
- import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
+ import { X, AlertTriangle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
- import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
- import { INPUT_CLASSES } from '$lib/constants/input-classes';
- import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
- import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
- import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
- import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
- import {
- autoResizeTextarea,
- getFileTypeCategory,
- getFileTypeCategoryByExtension,
- parseClipboardContent
- } from '$lib/utils';
+ import { ChatForm, DialogConfirmation } from '$lib/components/app';
+ import { getMessageEditContext } from '$lib/contexts';
+ import { KeyboardKey } from '$lib/enums';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { processFilesToChatUploaded } from '$lib/utils/browser-only';
- interface Props {
- messageId: string;
- editedContent: string;
- editedExtras?: DatabaseMessageExtra[];
- editedUploadedFiles?: ChatUploadedFile[];
- originalContent: string;
- originalExtras?: DatabaseMessageExtra[];
- showSaveOnlyOption?: boolean;
- onCancelEdit: () => void;
- onSaveEdit: () => void;
- onSaveEditOnly?: () => void;
- onEditKeydown: (event: KeyboardEvent) => void;
- onEditedContentChange: (content: string) => void;
- onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
- onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
- textareaElement?: HTMLTextAreaElement;
- }
-
- let {
- messageId,
- editedContent,
- editedExtras = [],
- editedUploadedFiles = [],
- originalContent,
- originalExtras = [],
- showSaveOnlyOption = false,
- onCancelEdit,
- onSaveEdit,
- onSaveEditOnly,
- onEditKeydown,
- onEditedContentChange,
- onEditedExtrasChange,
- onEditedUploadedFilesChange,
- textareaElement = $bindable()
- }: Props = $props();
+ const editCtx = getMessageEditContext();
- let fileInputElement: HTMLInputElement | undefined = $state();
+ let inputAreaRef: ChatForm | undefined = $state(undefined);
let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false);
- let isRouter = $derived(isRouterMode());
- let currentConfig = $derived(config());
-
- let pasteLongTextToFileLength = $derived.by(() => {
- const n = Number(currentConfig.pasteLongTextToFileLen);
-
- return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
- });
let hasUnsavedChanges = $derived.by(() => {
- if (editedContent !== originalContent) return true;
- if (editedUploadedFiles.length > 0) return true;
+ if (editCtx.editedContent !== editCtx.originalContent) return true;
+ if (editCtx.editedUploadedFiles.length > 0) return true;
const extrasChanged =
- editedExtras.length !== originalExtras.length ||
- editedExtras.some((extra, i) => extra !== originalExtras[i]);
+ editCtx.editedExtras.length !== editCtx.originalExtras.length ||
+ editCtx.editedExtras.some((extra, i) => extra !== editCtx.originalExtras[i]);
if (extrasChanged) return true;
});
let hasAttachments = $derived(
- (editedExtras && editedExtras.length > 0) ||
- (editedUploadedFiles && editedUploadedFiles.length > 0)
+ (editCtx.editedExtras && editCtx.editedExtras.length > 0) ||
+ (editCtx.editedUploadedFiles && editCtx.editedUploadedFiles.length > 0)
);
- let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
-
- function getEditedAttachmentsModalities(): ModelModalities {
- const modalities: ModelModalities = { vision: false, audio: false };
-
- for (const extra of editedExtras) {
- if (extra.type === AttachmentType.IMAGE) {
- modalities.vision = true;
- }
-
- if (
- extra.type === AttachmentType.PDF &&
- 'processedAsImages' in extra &&
- extra.processedAsImages
- ) {
- modalities.vision = true;
- }
-
- if (extra.type === AttachmentType.AUDIO) {
- modalities.audio = true;
- }
- }
-
- for (const file of editedUploadedFiles) {
- const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
- if (category === FileTypeCategory.IMAGE) {
- modalities.vision = true;
- }
- if (category === FileTypeCategory.AUDIO) {
- modalities.audio = true;
- }
- }
-
- return modalities;
- }
-
- function getRequiredModalities(): ModelModalities {
- const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
- const editedModalities = getEditedAttachmentsModalities();
-
- return {
- vision: beforeModalities.vision || editedModalities.vision,
- audio: beforeModalities.audio || editedModalities.audio
- };
- }
-
- const { handleModelChange } = useModelChangeValidation({
- getRequiredModalities,
- onValidationFailure: async (previousModelId: string | null) => {
- if (previousModelId) {
- await modelsStore.selectModelById(previousModelId);
- }
- }
- });
-
- function handleFileInputChange(event: Event) {
- const input = event.target as HTMLInputElement;
- if (!input.files || input.files.length === 0) return;
-
- const files = Array.from(input.files);
-
- processNewFiles(files);
- input.value = '';
- }
+ let canSubmit = $derived(editCtx.editedContent.trim().length > 0 || hasAttachments);
function handleGlobalKeydown(event: KeyboardEvent) {
- if (event.key === 'Escape') {
+ if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
attemptCancel();
}
if (hasUnsavedChanges) {
showDiscardDialog = true;
} else {
- onCancelEdit();
+ editCtx.cancel();
}
}
- function handleRemoveExistingAttachment(index: number) {
- if (!onEditedExtrasChange) return;
-
- const newExtras = [...editedExtras];
-
- newExtras.splice(index, 1);
- onEditedExtrasChange(newExtras);
- }
-
- function handleRemoveUploadedFile(fileId: string) {
- if (!onEditedUploadedFilesChange) return;
-
- const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
-
- onEditedUploadedFilesChange(newFiles);
- }
-
function handleSubmit() {
if (!canSubmit) return;
- if (saveWithoutRegenerate && onSaveEditOnly) {
- onSaveEditOnly();
+ if (saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
+ editCtx.saveOnly();
} else {
- onSaveEdit();
+ editCtx.save();
}
saveWithoutRegenerate = false;
}
- async function processNewFiles(files: File[]) {
- if (!onEditedUploadedFilesChange) return;
-
- const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
- const processed = await processFilesToChatUploaded(files);
-
- onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
+ function handleAttachmentRemove(index: number) {
+ const newExtras = [...editCtx.editedExtras];
+ newExtras.splice(index, 1);
+ editCtx.setExtras(newExtras);
}
- function handlePaste(event: ClipboardEvent) {
- if (!event.clipboardData) return;
-
- const files = Array.from(event.clipboardData.items)
- .filter((item) => item.kind === 'file')
- .map((item) => item.getAsFile())
- .filter((file): file is File => file !== null);
-
- if (files.length > 0) {
- event.preventDefault();
- processNewFiles(files);
-
- return;
- }
-
- const text = event.clipboardData.getData(MimeTypeText.PLAIN);
-
- if (text.startsWith('"')) {
- const parsed = parseClipboardContent(text);
-
- if (parsed.textAttachments.length > 0) {
- event.preventDefault();
- onEditedContentChange(parsed.message);
-
- const attachmentFiles = parsed.textAttachments.map(
- (att) =>
- new File([att.content], att.name, {
- type: MimeTypeText.PLAIN
- })
- );
-
- processNewFiles(attachmentFiles);
-
- setTimeout(() => {
- textareaElement?.focus();
- }, 10);
-
- return;
- }
- }
-
- if (
- text.length > 0 &&
- pasteLongTextToFileLength > 0 &&
- text.length > pasteLongTextToFileLength
- ) {
- event.preventDefault();
-
- const textFile = new File([text], 'Pasted', {
- type: MimeTypeText.PLAIN
- });
-
- processNewFiles([textFile]);
- }
+ function handleUploadedFileRemove(fileId: string) {
+ const newFiles = editCtx.editedUploadedFiles.filter((f) => f.id !== fileId);
+ editCtx.setUploadedFiles(newFiles);
}
- $effect(() => {
- if (textareaElement) {
- autoResizeTextarea(textareaElement);
- }
- });
+ async function handleFilesAdd(files: File[]) {
+ const processed = await processFilesToChatUploaded(files);
+ editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
+ }
$effect(() => {
- setEditModeActive(processNewFiles);
+ chatStore.setEditModeActive(handleFilesAdd);
return () => {
- clearEditMode();
+ chatStore.clearEditMode();
};
});
</script>
<svelte:window onkeydown={handleGlobalKeydown} />
-<input
- bind:this={fileInputElement}
- type="file"
- multiple
- class="hidden"
- onchange={handleFileInputChange}
-/>
-
-<div
- class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
- data-slot="edit-form"
->
- <ChatAttachmentsList
- attachments={editedExtras}
- uploadedFiles={editedUploadedFiles}
- readonly={false}
- onFileRemove={(fileId) => {
- if (fileId.startsWith('attachment-')) {
- const index = parseInt(fileId.replace('attachment-', ''), 10);
- if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
- handleRemoveExistingAttachment(index);
- }
- } else {
- handleRemoveUploadedFile(fileId);
- }
- }}
- limitToSingleRow
- class="py-5"
- style="scroll-padding: 1rem;"
+<div class="relative w-full max-w-[80%]">
+ <ChatForm
+ bind:this={inputAreaRef}
+ value={editCtx.editedContent}
+ attachments={editCtx.editedExtras}
+ uploadedFiles={editCtx.editedUploadedFiles}
+ placeholder="Edit your message..."
+ onValueChange={editCtx.setContent}
+ onAttachmentRemove={handleAttachmentRemove}
+ onUploadedFileRemove={handleUploadedFileRemove}
+ onFilesAdd={handleFilesAdd}
+ onSubmit={handleSubmit}
/>
-
- <div class="relative min-h-[48px] px-5 py-3">
- <textarea
- bind:this={textareaElement}
- bind:value={editedContent}
- class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
- onkeydown={onEditKeydown}
- oninput={(e) => {
- autoResizeTextarea(e.currentTarget);
- onEditedContentChange(e.currentTarget.value);
- }}
- onpaste={handlePaste}
- placeholder="Edit your message..."
- ></textarea>
-
- <div class="flex w-full items-center gap-3" style="container-type: inline-size">
- <Button
- class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
- onclick={() => fileInputElement?.click()}
- type="button"
- title="Add attachment"
- >
- <span class="sr-only">Attach files</span>
-
- <Paperclip class="h-4 w-4" />
- </Button>
-
- <div class="flex-1"></div>
-
- {#if isRouter}
- <ModelsSelector
- forceForegroundText={true}
- useGlobalSelection={true}
- onModelChange={handleModelChange}
- />
- {/if}
-
- <Button
- class="h-8 w-8 shrink-0 rounded-full p-0"
- onclick={handleSubmit}
- disabled={!canSubmit}
- type="button"
- title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
- >
- <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
-
- <ArrowUp class="h-5 w-5" />
- </Button>
- </div>
- </div>
</div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
- {#if showSaveOnlyOption && onSaveEditOnly}
+ {#if editCtx.showSaveOnlyOption}
<div class="flex items-center gap-2">
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
cancelText="Keep editing"
variant="destructive"
icon={AlertTriangle}
- onConfirm={onCancelEdit}
+ onConfirm={editCtx.cancel}
onCancel={() => (showDiscardDialog = false)}
/>
import { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
- import { formatPerformanceTime } from '$lib/utils/formatters';
+ import { formatPerformanceTime } from '$lib/utils';
+ import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters';
interface Props {
predictedTokens?: number;
predictedMs?: number;
promptTokens?: number;
promptMs?: number;
- // Live mode: when true, shows stats during streaming
isLive?: boolean;
- // Whether prompt processing is still in progress
isProcessingPrompt?: boolean;
- // Initial view to show (defaults to READING in live mode)
initialView?: ChatMessageStatsView;
+ onActiveViewChange?: (view: ChatMessageStatsView) => void;
}
let {
promptMs,
isLive = false,
isProcessingPrompt = false,
- initialView = ChatMessageStatsView.GENERATION
+ initialView = ChatMessageStatsView.GENERATION,
+ onActiveViewChange
}: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView);
let hasAutoSwitchedToGeneration = $state(false);
+ $effect(() => {
+ onActiveViewChange?.(activeView);
+ });
+
// In live mode: auto-switch to GENERATION tab when prompt processing completes
$effect(() => {
if (isLive) {
predictedMs > 0
);
- let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
+ let tokensPerSecond = $derived(
+ hasGenerationStats ? (predictedTokens! / predictedMs!) * MS_PER_SECOND : 0
+ );
let formattedTime = $derived(
- predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
+ predictedMs !== undefined ? formatPerformanceTime(predictedMs) : DEFAULT_PERFORMANCE_TIME
);
let promptTokensPerSecond = $derived(
promptTokens !== undefined && promptMs !== undefined && promptMs > 0
- ? (promptTokens / promptMs) * 1000
+ ? (promptTokens / promptMs) * MS_PER_SECOND
: undefined
);
onclick={() => (activeView = ChatMessageStatsView.READING)}
>
<BookOpenText class="h-3 w-3" />
+
<span class="sr-only">Reading</span>
</button>
</Tooltip.Trigger>
+
<Tooltip.Content>
<p>Reading (prompt processing)</p>
</Tooltip.Content>
disabled={isGenerationDisabled}
>
<Sparkles class="h-3 w-3" />
+
<span class="sr-only">Generation</span>
</button>
</Tooltip.Trigger>
+
<Tooltip.Content>
<p>
{isGenerationDisabled
value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens"
/>
+
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedTime}
tooltipLabel="Generation time"
/>
+
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
- value="{tokensPerSecond.toFixed(2)} tokens/s"
+ value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
/>
{:else if hasPromptStats}
value="{promptTokens} tokens"
tooltipLabel="Prompt tokens"
/>
+
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
/>
+
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
import { Card } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app';
- import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { getMessageEditContext } from '$lib/contexts';
+ import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { config } from '$lib/stores/settings.svelte';
+ import { isIMEComposing } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte';
+ import { KeyboardKey, MessageRole } from '$lib/enums';
interface Props {
class?: string;
message: DatabaseMessage;
- isEditing: boolean;
- editedContent: string;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
assistantMessages: number;
messageTypes: string[];
} | null;
- onCancelEdit: () => void;
- onSaveEdit: () => void;
- onEditKeydown: (event: KeyboardEvent) => void;
- onEditedContentChange: (content: string) => void;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
let {
class: className = '',
message,
- isEditing,
- editedContent,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
- onCancelEdit,
- onSaveEdit,
- onEditKeydown,
- onEditedContentChange,
onCopy,
onEdit,
onDelete,
textareaElement = $bindable()
}: Props = $props();
+ const editCtx = getMessageEditContext();
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+
+ editCtx.save();
+ } else if (event.key === KeyboardKey.ESCAPE) {
+ event.preventDefault();
+
+ editCtx.cancel();
+ }
+ }
+
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
let isExpanded = $state(false);
let contentHeight = $state(0);
+
const MAX_HEIGHT = 200; // pixels
const currentConfig = config();
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
- {#if isEditing}
+ {#if editCtx.isEditing}
<div class="w-full max-w-[80%]">
<textarea
bind:this={textareaElement}
- bind:value={editedContent}
+ value={editCtx.editedContent}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
- onkeydown={onEditKeydown}
- oninput={(e) => onEditedContentChange(e.currentTarget.value)}
+ onkeydown={handleEditKeydown}
+ oninput={(e) => editCtx.setContent(e.currentTarget.value)}
placeholder="Edit system message..."
></textarea>
<div class="mt-2 flex justify-end gap-2">
- <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
+
Cancel
</Button>
- <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
+ <Button
+ class="h-8 px-3"
+ onclick={editCtx.save}
+ disabled={!editCtx.editedContent.trim()}
+ size="sm"
+ >
<Check class="mr-1 h-3 w-3" />
+
Save
</Button>
</div>
type="button"
>
<Card
- class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
+ class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined}
- style="border: 2px dashed hsl(var(--border));"
+ style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
>
<div
- class="relative overflow-hidden transition-all duration-300 {isExpanded
+ class="relative transition-all duration-300 {isExpanded
? 'cursor-text select-text'
: 'select-none'}"
style={!isExpanded && showExpandButton
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
- <MarkdownContent class="markdown-system-content" content={message.content} />
+ <MarkdownContent
+ class="markdown-system-content overflow-auto"
+ content={message.content}
+ />
</div>
{:else}
<span
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
></div>
+
<div
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
>
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
- role="user"
+ role={MessageRole.USER}
/>
</div>
{/if}
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+ import { getMessageEditContext } from '$lib/contexts';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageActions from './ChatMessageActions.svelte';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
+ import { MessageRole } from '$lib/enums';
interface Props {
class?: string;
message: DatabaseMessage;
- isEditing: boolean;
- editedContent: string;
- editedExtras?: DatabaseMessageExtra[];
- editedUploadedFiles?: ChatUploadedFile[];
siblingInfo?: ChatMessageSiblingInfo | null;
- showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
- onCancelEdit: () => void;
- onSaveEdit: () => void;
- onSaveEditOnly?: () => void;
- onEditKeydown: (event: KeyboardEvent) => void;
- onEditedContentChange: (content: string) => void;
- onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
- onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
- onCopy: () => void;
+ showDeleteDialog: boolean;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
- onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
- textareaElement?: HTMLTextAreaElement;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onCopy: () => void;
}
let {
class: className = '',
message,
- isEditing,
- editedContent,
- editedExtras = [],
- editedUploadedFiles = [],
siblingInfo = null,
- showDeleteDialog,
deletionInfo,
- onCancelEdit,
- onSaveEdit,
- onSaveEditOnly,
- onEditKeydown,
- onEditedContentChange,
- onEditedExtrasChange,
- onEditedUploadedFilesChange,
- onCopy,
+ showDeleteDialog,
onEdit,
onDelete,
onConfirmDelete,
- onNavigateToSibling,
onShowDeleteDialogChange,
- textareaElement = $bindable()
+ onNavigateToSibling,
+ onCopy
}: Props = $props();
+ // Get contexts
+ const editCtx = getMessageEditContext();
+
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
const currentConfig = config();
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
- {#if isEditing}
- <ChatMessageEditForm
- bind:textareaElement
- messageId={message.id}
- {editedContent}
- {editedExtras}
- {editedUploadedFiles}
- originalContent={message.content}
- originalExtras={message.extra}
- showSaveOnlyOption={!!onSaveEditOnly}
- {onCancelEdit}
- {onSaveEdit}
- {onSaveEditOnly}
- {onEditKeydown}
- {onEditedContentChange}
- {onEditedExtrasChange}
- {onEditedUploadedFilesChange}
- />
+ {#if editCtx.isEditing}
+ <ChatMessageEditForm />
{:else}
{#if message.extra && message.extra.length > 0}
<div class="mb-2 max-w-[80%]">
{#if message.content.trim()}
<Card
- class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
+ class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 text-foreground backdrop-blur-md data-[multiline]:py-2.5 dark:bg-primary/15"
data-multiline={isMultiline ? '' : undefined}
+ style="max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
>
{#if currentConfig.renderUserContentAsMarkdown}
- <div bind:this={messageElement} class="text-md">
- <MarkdownContent
- class="markdown-user-content text-primary-foreground"
- content={message.content}
- />
+ <div bind:this={messageElement}>
+ <MarkdownContent class="markdown-user-content -my-4" content={message.content} />
</div>
{:else}
<span bind:this={messageElement} class="text-md whitespace-pre-wrap">
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
- role="user"
+ role={MessageRole.USER}
/>
</div>
{/if}
<script lang="ts">
import { ChatMessage } from '$lib/components/app';
+ import { setChatActionsContext } from '$lib/contexts';
+ import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { getMessageSiblings } from '$lib/utils';
+ import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
interface Props {
class?: string;
let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
+ setChatActionsContext({
+ copy: async (message: DatabaseMessage) => {
+ const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
+ const clipboardContent = formatMessageForClipboard(
+ message.content,
+ message.extra,
+ asPlainText
+ );
+ await copyToClipboard(clipboardContent, 'Message copied to clipboard');
+ },
+
+ delete: async (message: DatabaseMessage) => {
+ await chatStore.deleteMessage(message.id);
+ refreshAllMessages();
+ },
+
+ navigateToSibling: async (siblingId: string) => {
+ await conversationsStore.navigateToSibling(siblingId);
+ },
+
+ editWithBranching: async (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => {
+ onUserAction?.();
+ await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
+ refreshAllMessages();
+ },
+
+ editWithReplacement: async (
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) => {
+ onUserAction?.();
+ await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
+ refreshAllMessages();
+ },
+
+ editUserMessagePreserveResponses: async (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => {
+ onUserAction?.();
+ await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
+ refreshAllMessages();
+ },
+
+ regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
+ onUserAction?.();
+ await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
+ refreshAllMessages();
+ },
+
+ continueAssistantMessage: async (message: DatabaseMessage) => {
+ onUserAction?.();
+ await chatStore.continueAssistantMessage(message.id);
+ refreshAllMessages();
+ }
+ });
+
function refreshAllMessages() {
const conversation = activeConversation();
return [];
}
- // Filter out system messages if showSystemMessage is false
const filteredMessages = currentConfig.showSystemMessage
? messages
- : messages.filter((msg) => msg.type !== 'system');
+ : messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
+
+ let lastAssistantIndex = -1;
+
+ for (let i = filteredMessages.length - 1; i >= 0; i--) {
+ if (filteredMessages[i].role === MessageRole.ASSISTANT) {
+ lastAssistantIndex = i;
+
+ break;
+ }
+ }
- return filteredMessages.map((message) => {
+ return filteredMessages.map((message, index) => {
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
+ const isLastAssistantMessage =
+ message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
return {
message,
+ isLastAssistantMessage,
siblingInfo: siblingInfo || {
message,
siblingIds: [message.id],
};
});
});
-
- async function handleNavigateToSibling(siblingId: string) {
- await conversationsStore.navigateToSibling(siblingId);
- }
-
- async function handleEditWithBranching(
- message: DatabaseMessage,
- newContent: string,
- newExtras?: DatabaseMessageExtra[]
- ) {
- onUserAction?.();
-
- await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
-
- refreshAllMessages();
- }
-
- async function handleEditWithReplacement(
- message: DatabaseMessage,
- newContent: string,
- shouldBranch: boolean
- ) {
- onUserAction?.();
-
- await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
-
- refreshAllMessages();
- }
-
- async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
- onUserAction?.();
-
- await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
-
- refreshAllMessages();
- }
-
- async function handleContinueAssistantMessage(message: DatabaseMessage) {
- onUserAction?.();
-
- await chatStore.continueAssistantMessage(message.id);
-
- refreshAllMessages();
- }
-
- async function handleEditUserMessagePreserveResponses(
- message: DatabaseMessage,
- newContent: string,
- newExtras?: DatabaseMessageExtra[]
- ) {
- onUserAction?.();
-
- await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
-
- refreshAllMessages();
- }
-
- async function handleDeleteMessage(message: DatabaseMessage) {
- await chatStore.deleteMessage(message.id);
-
- refreshAllMessages();
- }
</script>
-<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
- {#each displayMessages as { message, siblingInfo } (message.id)}
+<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
+ {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
<ChatMessage
class="mx-auto w-full max-w-[48rem]"
{message}
+ {isLastAssistantMessage}
{siblingInfo}
- onDelete={handleDeleteMessage}
- onNavigateToSibling={handleNavigateToSibling}
- onEditWithBranching={handleEditWithBranching}
- onEditWithReplacement={handleEditWithReplacement}
- onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
- onRegenerateWithBranching={handleRegenerateWithBranching}
- onContinueAssistantMessage={handleContinueAssistantMessage}
/>
{/each}
</div>
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import {
- ChatForm,
+ ChatScreenForm,
ChatScreenHeader,
ChatMessages,
ChatScreenProcessingInfo,
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import {
- AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
- AUTO_SCROLL_INTERVAL,
- INITIAL_SCROLL_DELAY
- } from '$lib/constants/auto-scroll';
+ import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
+ import { KeyboardKey } from '$lib/enums';
+ import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import {
chatStore,
errorDialog,
let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
- let autoScrollEnabled = $state(true);
let chatScrollContainer: HTMLDivElement | undefined = $state();
let dragCounter = $state(0);
let isDragOver = $state(false);
- let lastScrollTop = $state(0);
- let scrollInterval: ReturnType<typeof setInterval> | undefined;
- let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
let showFileErrorDialog = $state(false);
let uploadedFiles = $state<ChatUploadedFile[]>([]);
- let userScrolledUp = $state(false);
+
+ const autoScroll = createAutoScrollController();
let fileErrorData = $state<{
generallyUnsupported: File[];
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
- if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
+ if (
+ isCtrlOrCmd &&
+ event.shiftKey &&
+ (event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
+ ) {
event.preventDefault();
if (activeConversation()) {
showDeleteDialog = true;
}
function handleScroll() {
- if (disableAutoScroll || !chatScrollContainer) return;
-
- const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
- const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
-
- if (scrollTop < lastScrollTop && !isAtBottom) {
- userScrolledUp = true;
- autoScrollEnabled = false;
- } else if (isAtBottom && userScrolledUp) {
- userScrolledUp = false;
- autoScrollEnabled = true;
- }
-
- if (scrollTimeout) {
- clearTimeout(scrollTimeout);
- }
-
- scrollTimeout = setTimeout(() => {
- if (isAtBottom) {
- userScrolledUp = false;
- autoScrollEnabled = true;
- }
- }, AUTO_SCROLL_INTERVAL);
-
- lastScrollTop = scrollTop;
+ autoScroll.handleScroll();
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
- const result = files
- ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
+ const plainFiles = files ? $state.snapshot(files) : undefined;
+ const result = plainFiles
+ ? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {
const extras = result?.extras;
// Enable autoscroll for user-initiated message sending
- if (!disableAutoScroll) {
- userScrolledUp = false;
- autoScrollEnabled = true;
- }
+ autoScroll.enable();
await chatStore.sendMessage(message, extras);
- scrollChatToBottom();
+ autoScroll.scrollToBottom();
return true;
}
}
}
- function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
- if (disableAutoScroll) return;
-
- chatScrollContainer?.scrollTo({
- top: chatScrollContainer?.scrollHeight,
- behavior
- });
- }
-
afterNavigate(() => {
if (!disableAutoScroll) {
- setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
}
});
onMount(() => {
if (!disableAutoScroll) {
- setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
}
const pendingDraft = chatStore.consumePendingDraft();
});
$effect(() => {
- if (disableAutoScroll) {
- autoScrollEnabled = false;
- if (scrollInterval) {
- clearInterval(scrollInterval);
- scrollInterval = undefined;
- }
- return;
- }
+ autoScroll.setContainer(chatScrollContainer);
+ });
- if (isCurrentConversationLoading && autoScrollEnabled) {
- scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
- } else if (scrollInterval) {
- clearInterval(scrollInterval);
- scrollInterval = undefined;
- }
+ $effect(() => {
+ autoScroll.setDisabled(disableAutoScroll);
+ });
+
+ $effect(() => {
+ autoScroll.updateInterval(isCurrentConversationLoading);
});
</script>
class="mb-16 md:mb-24"
messages={activeMessages()}
onUserAction={() => {
- if (!disableAutoScroll) {
- userScrolledUp = false;
- autoScrollEnabled = true;
- scrollChatToBottom();
- }
+ autoScroll.enable();
+ autoScroll.scrollToBottom();
}}
/>
{/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
- <ChatForm
+ <ChatScreenForm
disabled={hasPropsError || isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
>
<div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
- <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
+ <h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<p class="text-lg text-muted-foreground">
{serverStore.props?.modalities?.audio
{/if}
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
- <ChatForm
+ <ChatScreenForm
disabled={hasPropsError}
{initialMessage}
isLoading={isCurrentConversationLoading}
contextInfo={activeErrorDialog?.contextInfo}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)}
- type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER}
+ type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
/>
<style>
<script lang="ts">
- import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
+ import { afterNavigate } from '$app/navigation';
+ import { ChatFormHelperText, ChatForm } from '$lib/components/app';
+ import { onMount } from 'svelte';
interface Props {
class?: string;
showHelperText = true,
uploadedFiles = $bindable([])
}: Props = $props();
+
+ let chatFormRef: ChatForm | undefined = $state(undefined);
+ let message = $derived(initialMessage);
+ let previousIsLoading = $derived(isLoading);
+ let previousInitialMessage = $derived(initialMessage);
+
+ // Sync message when initialMessage prop changes (e.g., after draft restoration)
+ $effect(() => {
+ if (initialMessage !== previousInitialMessage) {
+ message = initialMessage;
+ previousInitialMessage = initialMessage;
+ }
+ });
+
+ function handleSystemPromptClick() {
+ onSystemPromptAdd?.({ message, files: uploadedFiles });
+ }
+
+ let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
+
+ async function handleSubmit() {
+ if (
+ (!message.trim() && uploadedFiles.length === 0) ||
+ disabled ||
+ isLoading ||
+ hasLoadingAttachments
+ )
+ return;
+
+ if (!chatFormRef?.checkModelSelected()) return;
+
+ const messageToSend = message.trim();
+ const filesToSend = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+
+ chatFormRef?.resetTextareaHeight();
+
+ const success = await onSend?.(messageToSend, filesToSend);
+
+ if (!success) {
+ message = messageToSend;
+ uploadedFiles = filesToSend;
+ }
+ }
+
+ function handleFilesAdd(files: File[]) {
+ onFileUpload?.(files);
+ }
+
+ function handleUploadedFileRemove(fileId: string) {
+ onFileRemove?.(fileId);
+ }
+
+ onMount(() => {
+ setTimeout(() => chatFormRef?.focus(), 10);
+ });
+
+ afterNavigate(() => {
+ setTimeout(() => chatFormRef?.focus(), 10);
+ });
+
+ $effect(() => {
+ if (previousIsLoading && !isLoading) {
+ setTimeout(() => chatFormRef?.focus(), 10);
+ }
+
+ previousIsLoading = isLoading;
+ });
</script>
<div class="relative mx-auto max-w-[48rem]">
<ChatForm
+ bind:this={chatFormRef}
+ bind:value={message}
+ bind:uploadedFiles
class={className}
{disabled}
- {initialMessage}
{isLoading}
- {onFileRemove}
- {onFileUpload}
- {onSend}
+ onFilesAdd={handleFilesAdd}
{onStop}
- {onSystemPromptAdd}
- {showHelperText}
- bind:uploadedFiles
+ onSubmit={handleSubmit}
+ onSystemPromptClick={handleSystemPromptClick}
+ onUploadedFileRemove={handleUploadedFileRemove}
/>
</div>
+
+<ChatFormHelperText show={showHelperText} />
AlertTriangle,
Code,
Monitor,
- Sun,
- Moon,
ChevronLeft,
ChevronRight,
Database
type SettingsSectionTitle
} from '$lib/constants/settings-sections';
import { setMode } from 'mode-watcher';
+ import { ColorMode } from '$lib/enums/ui';
+ import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte';
+ import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
+ import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
+ import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props {
onSave?: () => void;
title: SettingsSectionTitle;
}> = [
{
- title: 'General',
+ title: SETTINGS_SECTION_TITLES.GENERAL,
icon: Settings,
fields: [
{
- key: 'theme',
+ key: SETTINGS_KEYS.THEME,
label: 'Theme',
- type: 'select',
- options: [
- { value: 'system', label: 'System', icon: Monitor },
- { value: 'light', label: 'Light', icon: Sun },
- { value: 'dark', label: 'Dark', icon: Moon }
- ]
+ type: SettingsFieldType.SELECT,
+ options: SETTINGS_COLOR_MODES_CONFIG
},
- { key: 'apiKey', label: 'API Key', type: 'input' },
+ { key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{
- key: 'systemMessage',
+ key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
- type: 'textarea'
+ type: SettingsFieldType.TEXTAREA
},
{
- key: 'pasteLongTextToFileLen',
+ key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'copyTextAttachmentsAsPlainText',
+ key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'enableContinueGeneration',
+ key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
- type: 'checkbox',
+ type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
- key: 'pdfAsImage',
+ key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'askForTitleConfirmation',
+ key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
}
]
},
{
- title: 'Display',
+ title: SETTINGS_SECTION_TITLES.DISPLAY,
icon: Monitor,
fields: [
{
- key: 'showMessageStats',
+ key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'showThoughtInProgress',
+ key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'keepStatsVisible',
+ key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'autoMicOnEmpty',
+ key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
- type: 'checkbox',
+ type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
- key: 'renderUserContentAsMarkdown',
+ key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'disableAutoScroll',
+ key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'alwaysShowSidebarOnDesktop',
+ key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'autoShowSidebarOnNewChat',
+ key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
}
]
},
{
- title: 'Sampling',
+ title: SETTINGS_SECTION_TITLES.SAMPLING,
icon: Funnel,
fields: [
{
- key: 'temperature',
+ key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dynatemp_range',
+ key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dynatemp_exponent',
+ key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'top_k',
+ key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'top_p',
+ key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'min_p',
+ key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'xtc_probability',
+ key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'xtc_threshold',
+ key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'typ_p',
+ key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'max_tokens',
+ key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'samplers',
+ key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'backend_sampling',
+ key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
}
]
},
{
- title: 'Penalties',
+ title: SETTINGS_SECTION_TITLES.PENALTIES,
icon: AlertTriangle,
fields: [
{
- key: 'repeat_last_n',
+ key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'repeat_penalty',
+ key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'presence_penalty',
+ key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'frequency_penalty',
+ key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dry_multiplier',
+ key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dry_base',
+ key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dry_allowed_length',
+ key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
- type: 'input'
+ type: SettingsFieldType.INPUT
},
{
- key: 'dry_penalty_last_n',
+ key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
- type: 'input'
+ type: SettingsFieldType.INPUT
}
]
},
{
- title: 'Import/Export',
+ title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
icon: Database,
fields: []
},
{
- title: 'Developer',
+ title: SETTINGS_SECTION_TITLES.DEVELOPER,
icon: Code,
fields: [
{
- key: 'showToolCalls',
- label: 'Show tool call labels',
- type: 'checkbox'
- },
- {
- key: 'disableReasoningParsing',
+ key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable reasoning content parsing',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'showRawOutputSwitch',
+ key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
- type: 'checkbox'
+ type: SettingsFieldType.CHECKBOX
},
{
- key: 'custom',
+ key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
- type: 'textarea'
+ type: SettingsFieldType.TEXTAREA
}
]
}
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
- if (!initialSection) {
- return;
- }
-
- if (settingSections.some((section) => section.title === initialSection)) {
+ if (initialSection) {
activeSection = initialSection;
}
});
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;
- setMode(newTheme as 'light' | 'dark' | 'system');
+ setMode(newTheme as ColorMode);
}
function handleConfigChange(key: string, value: string | boolean) {
function handleReset() {
localConfig = { ...config() };
- setMode(localConfig.theme as 'light' | 'dark' | 'system');
+ setMode(localConfig.theme as ColorMode);
}
function handleSave() {
// Convert numeric strings to numbers for numeric fields
const processedConfig = { ...localConfig };
- const numericFields = [
- 'temperature',
- 'top_k',
- 'top_p',
- 'min_p',
- 'max_tokens',
- 'pasteLongTextToFileLen',
- 'dynatemp_range',
- 'dynatemp_exponent',
- 'typ_p',
- 'xtc_probability',
- 'xtc_threshold',
- 'repeat_last_n',
- 'repeat_penalty',
- 'presence_penalty',
- 'frequency_penalty',
- 'dry_multiplier',
- 'dry_base',
- 'dry_allowed_length',
- 'dry_penalty_last_n'
- ];
-
- for (const field of numericFields) {
+
+ for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) {
- processedConfig[field] = numValue;
+ if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
+ processedConfig[field] = Math.max(1, Math.round(numValue));
+ } else {
+ processedConfig[field] = numValue;
+ }
} else {
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
return;
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
- {#if currentSection.title === 'Import/Export'}
+ {#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<ChatSettingsImportExportTab />
{:else}
<div class="space-y-6">
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
+ import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+ import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
import type { Component } from 'svelte';
{#each fields as field (field.key)}
<div class="space-y-2">
- {#if field.type === 'input'}
+ {#if field.type === SettingsFieldType.INPUT}
{@const paramInfo = getParameterSourceInfo(field.key)}
{@const currentValue = String(localConfig[field.key] ?? '')}
{@const propsDefault = paramInfo?.serverDefault}
{@html field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
- {:else if field.type === 'textarea'}
+ {:else if field.type === SettingsFieldType.TEXTAREA}
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
{field.label}
</p>
{/if}
- {#if field.key === 'systemMessage'}
+ {#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
<div class="mt-3 flex items-center gap-2">
<Checkbox
id="showSystemMessage"
</Label>
</div>
{/if}
- {:else if field.type === 'select'}
+ {:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
opt.value === localConfig[field.key]
type="single"
value={currentValue}
onValueChange={(value) => {
- if (field.key === 'theme' && value && onThemeChange) {
+ if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
onThemeChange(value);
} else {
onConfigChange(field.key, value);
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
- {:else if field.type === 'checkbox'}
+ {:else if field.type === SettingsFieldType.CHECKBOX}
<div class="flex items-start space-x-3">
<Checkbox
id={field.key}
--- /dev/null
+/**
+ *
+ * ATTACHMENTS
+ *
+ * Components for displaying and managing different attachment types in chat messages.
+ * Supports two operational modes:
+ * - **Readonly mode**: For displaying stored attachments in sent messages (DatabaseMessageExtra[])
+ * - **Editable mode**: For managing pending uploads in the input form (ChatUploadedFile[])
+ *
+ * The attachment system uses `getAttachmentDisplayItems()` utility to normalize both
+ * data sources into a unified display format, enabling consistent rendering regardless
+ * of the attachment origin.
+ *
+ */
+
+/**
+ * **ChatAttachmentsList** - Unified display for file attachments in chat
+ *
+ * Central component for rendering file attachments in both ChatMessage (readonly)
+ * and ChatForm (editable) contexts.
+ *
+ * **Architecture:**
+ * - Delegates rendering to specialized thumbnail components based on attachment type
+ * - Manages scroll state and navigation arrows for horizontal overflow
+ * - Integrates with DialogChatAttachmentPreview for full-size viewing
+ * - Validates vision modality support via `activeModelId` prop
+ *
+ * **Features:**
+ * - Horizontal scroll with smooth navigation arrows
+ * - Image thumbnails with lazy loading and error fallback
+ * - File type icons for non-image files (PDF, text, audio, etc.)
+ * - Click-to-preview with full-size dialog and download option
+ * - "View All" button when `limitToSingleRow` is enabled and content overflows
+ * - Vision modality validation to warn about unsupported image uploads
+ * - Customizable thumbnail dimensions via `imageHeight`/`imageWidth` props
+ *
+ * @example
+ * ```svelte
+ * <!-- Readonly mode (in ChatMessage) -->
+ * <ChatAttachmentsList attachments={message.extra} readonly />
+ *
+ * <!-- Editable mode (in ChatForm) -->
+ * <ChatAttachmentsList
+ * bind:uploadedFiles
+ * onFileRemove={(id) => removeFile(id)}
+ * limitToSingleRow
+ * activeModelId={selectedModel}
+ * />
+ * ```
+ */
+export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
+
+/**
+ * Thumbnail for non-image file attachments. Displays file type icon based on extension,
+ * file name (truncated), and file size.
+ * Handles text files, PDFs, audio, and other document types.
+ */
+export { default as ChatAttachmentThumbnailFile } from './ChatAttachments/ChatAttachmentThumbnailFile.svelte';
+
+/**
+ * Thumbnail for image attachments with lazy loading and error fallback.
+ * Displays image preview with configurable dimensions. Falls back to placeholder
+ * on load error.
+ */
+export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatAttachmentThumbnailImage.svelte';
+
+/**
+ * Grid view of all attachments for "View All" dialog. Displays all attachments
+ * in a responsive grid layout when there are too many to show inline.
+ * Triggered by "+X more" button in ChatAttachmentsList.
+ */
+export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
+
+/**
+ * Full-size preview dialog for attachments. Opens when clicking on any attachment
+ * thumbnail. Shows the attachment in full size with options to download or close.
+ * Handles both image and non-image attachments with appropriate rendering.
+ */
+export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
+/**
+ *
+ * FORM
+ *
+ * Components for the chat input area. The form handles user input, file attachments,
+ * audio recording. It integrates with multiple stores:
+ * - `chatStore` for message submission and generation control
+ * - `modelsStore` for model selection and validation
+ *
+ * The form exposes a public API for programmatic control from parent components
+ * (focus, height reset, model selector, validation).
+ *
+ */
+
+/**
+ * **ChatForm** - Main chat input component with rich features
+ *
+ * The primary input interface for composing and sending chat messages.
+ * Orchestrates text input, file attachments, audio recording.
+ * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
+ *
+ * **Architecture:**
+ * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPromptPicker
+ * - Manages file upload state via `uploadedFiles` bindable prop
+ * - Integrates with ModelsSelector for model selection in router mode
+ * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
+ *
+ * **Input Handling:**
+ * - IME-safe Enter key handling (waits for composition end)
+ * - Shift+Enter for newline, Enter for submit
+ * - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion)
+ *
+ * **Features:**
+ * - Auto-resizing textarea with placeholder
+ * - File upload via button dropdown (images/text/PDF), drag-drop, or paste
+ * - Audio recording with WAV conversion (when model supports audio)
+ * - Model selector integration (router mode)
+ * - Loading state with stop button, disabled state for errors
+ *
+ * **Exported API:**
+ * - `focus()` - Focus the textarea programmatically
+ * - `resetTextareaHeight()` - Reset textarea to default height after submit
+ * - `openModelSelector()` - Open model selection dropdown
+ * - `checkModelSelected(): boolean` - Validate model selection, show error if none
+ *
+ * @example
+ * ```svelte
+ * <ChatForm
+ * bind:this={chatFormRef}
+ * bind:value={message}
+ * bind:uploadedFiles
+ * {isLoading}
+ * onSubmit={handleSubmit}
+ * onFilesAdd={processFiles}
+ * onStop={handleStop}
+ * />
+ * ```
+ */
+export { default as ChatForm } from './ChatForm/ChatForm.svelte';
+
+/**
+ * Dropdown button for file attachment selection. Opens a menu with options for
+ * Images, Text Files, and PDF Files. Each option filters the file picker to
+ * appropriate types. Images option is disabled when model lacks vision modality.
+ */
+export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
+
+/**
+ * Audio recording button with real-time recording indicator. Records audio
+ * and converts to WAV format for upload. Only visible when the active model
+ * supports audio modality and setting for automatic audio input is enabled. Shows recording duration while active.
+ */
+export { default as ChatFormActionRecord } from './ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
+
+/**
+ * Container for chat form action buttons. Arranges file attachment, audio record,
+ * and submit/stop buttons in a horizontal layout. Handles conditional visibility
+ * based on model capabilities and loading state.
+ */
+export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormActions.svelte';
+
+/**
+ * Submit/stop button with loading state. Shows send icon normally, transforms
+ * to stop icon during generation. Disabled when input is empty or form is disabled.
+ * Triggers onSubmit or onStop callbacks based on current state.
+ */
+export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
+
+/**
+ * Hidden file input element for programmatic file selection.
+ */
+export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
+
+/**
+ * Helper text display below chat.
+ */
+export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.svelte';
+
+/**
+ * Auto-resizing textarea with IME composition support. Automatically adjusts
+ * height based on content. Handles IME input correctly (waits for composition
+ * end before processing Enter key). Exposes focus() and resetHeight() methods.
+ */
+export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
+
+/**
+ *
+ * MESSAGES
+ *
+ * Components for displaying chat messages. The message system supports:
+ * - **Conversation branching**: Messages can have siblings (alternative versions)
+ * created by editing or regenerating. Users can navigate between branches.
+ * - **Role-based rendering**: Different layouts for user, assistant, and system messages
+ * - **Streaming support**: Real-time display of assistant responses as they generate
+ * - **Agentic workflows**: Special rendering for tool calls and reasoning blocks
+ *
+ * The branching system uses `getMessageSiblings()` utility to compute sibling info
+ * for each message based on the full conversation tree stored in the database.
+ *
+ */
+
+/**
+ * **ChatMessages** - Message list container with branching support
+ *
+ * Container component that renders the list of messages in a conversation.
+ * Computes sibling information for each message to enable branch navigation.
+ * Integrates with conversationsStore for message operations.
+ *
+ * **Architecture:**
+ * - Fetches all conversation messages to compute sibling relationships
+ * - Filters system messages based on user config (`showSystemMessage`)
+ * - Delegates rendering to ChatMessage for each message
+ * - Propagates all message operations to chatStore via callbacks
+ *
+ * **Branching Logic:**
+ * - Uses `getMessageSiblings()` to find all messages with same parent
+ * - Computes `siblingInfo: { currentIndex, totalSiblings, siblingIds }`
+ * - Enables navigation between alternative message versions
+ *
+ * **Message Operations (delegated to chatStore):**
+ * - Edit with branching: Creates new message branch, preserves original
+ * - Edit with replacement: Modifies message in place
+ * - Regenerate: Creates new assistant response as sibling
+ * - Delete: Removes message and all descendants (cascade)
+ * - Continue: Appends to incomplete assistant message
+ *
+ * @example
+ * ```svelte
+ * <ChatMessages
+ * messages={activeMessages()}
+ * onUserAction={resetAutoScroll}
+ * />
+ * ```
+ */
+export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
+
+/**
+ * **ChatMessage** - Single message display with actions
+ *
+ * Renders a single chat message with role-specific styling and full action
+ * support. Delegates to specialized components based on message role:
+ * ChatMessageUser, ChatMessageAssistant, or ChatMessageSystem.
+ *
+ * **Architecture:**
+ * - Routes to role-specific component based on `message.type`
+ * - Manages edit mode state and inline editing UI
+ * - Handles action callbacks (copy, edit, delete, regenerate)
+ * - Displays branching controls when message has siblings
+ *
+ * **User Messages:**
+ * - Shows attachments via ChatAttachmentsList
+ * - Edit creates new branch or preserves responses
+ *
+ * **Assistant Messages:**
+ * - Renders content via MarkdownContent or ChatMessageAgenticContent
+ * - Shows model info badge (when enabled)
+ * - Regenerate creates sibling with optional model override
+ * - Continue action for incomplete responses
+ *
+ * **Features:**
+ * - Inline editing with file attachments support
+ * - Copy formatted content to clipboard
+ * - Delete with confirmation (shows cascade delete count)
+ * - Branching controls for sibling navigation
+ * - Statistics display (tokens, timing)
+ *
+ * @example
+ * ```svelte
+ * <ChatMessage
+ * {message}
+ * {siblingInfo}
+ * onEditWithBranching={handleEdit}
+ * onRegenerateWithBranching={handleRegenerate}
+ * onNavigateToSibling={handleNavigate}
+ * />
+ * ```
+ */
+export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
+
+/**
+ * Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
+ * buttons based on message role. Includes branching controls when message has siblings.
+ * Shows delete confirmation dialog with cascade delete count. Handles raw output toggle
+ * for assistant messages.
+ */
+export { default as ChatMessageActions } from './ChatMessages/ChatMessageActions.svelte';
+
+/**
+ * Navigation controls for message siblings (conversation branches). Displays
+ * prev/next arrows with current position counter (e.g., "2/5"). Enables users
+ * to navigate between alternative versions of a message created by editing
+ * or regenerating. Uses `conversationsStore.navigateToSibling()` for navigation.
+ */
+export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMessageBranchingControls.svelte';
+
+/**
+ * Statistics display for assistant messages. Shows token counts (prompt/completion),
+ * generation timing, tokens per second, and model name (when enabled in settings).
+ * Data sourced from message.timings stored during generation.
+ */
+export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
+
+/**
+ * System message display component. Renders system messages with distinct styling.
+ * Visibility controlled by `showSystemMessage` config setting.
+ */
+export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.svelte';
+
+/**
+ * User message display component. Renders user messages with right-aligned bubble styling.
+ * Shows message content, attachments via ChatAttachmentsList.
+ * Supports inline editing mode with ChatMessageEditForm integration.
+ */
+export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
+
+/**
+ * Assistant message display component. Renders assistant responses with left-aligned styling.
+ * Supports both plain markdown content (via MarkdownContent) and agentic content with tool calls
+ * (via ChatMessageAgenticContent). Shows model info badge, statistics, and action buttons.
+ * Handles streaming state with real-time content updates.
+ */
+export { default as ChatMessageAssistant } from './ChatMessages/ChatMessageAssistant.svelte';
+
+/**
+ * Inline message editing form. Provides textarea for editing message content with
+ * attachment management. Shows save/cancel buttons and optional "Save only" button
+ * for editing without regenerating responses. Used within ChatMessage components
+ * when user enters edit mode.
+ */
+export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte';
+
+/**
+ *
+ * SCREEN
+ *
+ * Top-level chat interface components. ChatScreen is the main container that
+ * orchestrates all chat functionality. It integrates with multiple stores:
+ * - `chatStore` for message operations and generation control
+ * - `conversationsStore` for conversation management
+ * - `serverStore` for server connection state
+ * - `modelsStore` for model capabilities (vision, audio modalities)
+ *
+ * The screen handles the complete chat lifecycle from empty state to active
+ * conversation with streaming responses.
+ *
+ */
+
+/**
+ * **ChatScreen** - Main chat interface container
+ *
+ * Top-level component that orchestrates the entire chat interface. Manages
+ * messages display, input form, file handling, auto-scroll, error dialogs,
+ * and server state. Used as the main content area in chat routes.
+ *
+ * **Architecture:**
+ * - Composes ChatMessages, ChatScreenForm, ChatScreenHeader, and dialogs
+ * - Manages auto-scroll via `createAutoScrollController()` hook
+ * - Handles file upload pipeline (validation → processing → state update)
+ * - Integrates with serverStore for loading/error/warning states
+ * - Tracks active model for modality validation (vision, audio)
+ *
+ * **File Upload Pipeline:**
+ * 1. Files received via drag-drop, paste, or file picker
+ * 2. Validated against supported types (`isFileTypeSupported()`)
+ * 3. Filtered by model modalities (`filterFilesByModalities()`)
+ * 4. Empty files detected and reported via DialogEmptyFileAlert
+ * 5. Valid files processed to ChatUploadedFile[] format
+ * 6. Unsupported files shown in error dialog with reasons
+ *
+ * **State Management:**
+ * - `isEmpty`: Shows centered welcome UI when no conversation active
+ * - `isCurrentConversationLoading`: Tracks generation state for current chat
+ * - `activeModelId`: Determines available modalities for file validation
+ * - `uploadedFiles`: Pending file attachments for next message
+ *
+ * **Features:**
+ * - Messages display with smart auto-scroll (pauses on user scroll up)
+ * - File drag-drop with visual overlay indicator
+ * - File validation with detailed error messages
+ * - Error dialog management (chat errors, model unavailable)
+ * - Server loading/error/warning states with appropriate UI
+ * - Conversation deletion with confirmation dialog
+ * - Processing info display (tokens/sec, timing) during generation
+ * - Keyboard shortcuts (Ctrl+Shift+Backspace to delete conversation)
+ *
+ * @example
+ * ```svelte
+ * <!-- In chat route -->
+ * <ChatScreen showCenteredEmpty={true} />
+ *
+ * <!-- In conversation route -->
+ * <ChatScreen showCenteredEmpty={false} />
+ * ```
+ */
+export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte';
+
+/**
+ * Visual overlay displayed when user drags files over the chat screen.
+ * Shows drop zone indicator to guide users where to release files.
+ * Integrated with ChatScreen's drag-drop file upload handling.
+ */
+export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOverlay.svelte';
+
+/**
+ * Chat form wrapper within ChatScreen. Positions the ChatForm component at the
+ * bottom of the screen with proper padding and max-width constraints. Handles
+ * the visual container styling for the input area.
+ */
+export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
+
+/**
+ * Header bar for chat screen. Displays conversation title (or "New Chat"),
+ * model selector (in router mode), and action buttons (delete conversation).
+ * Sticky positioned at the top of the chat area.
+ */
+export { default as ChatScreenHeader } from './ChatScreen/ChatScreenHeader.svelte';
+
+/**
+ * Processing info display during generation. Shows real-time statistics:
+ * tokens per second, prompt/completion token counts, and elapsed time.
+ * Data sourced from slotsService polling during active generation.
+ * Only visible when `isCurrentConversationLoading` is true.
+ */
+export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
+
+/**
+ *
+ * SETTINGS
+ *
+ * Application settings components. Settings are persisted to localStorage via
+ * the config store and synchronized with server `/props` endpoint for sampling
+ * parameters. The settings panel uses a tabbed interface with mobile-responsive
+ * horizontal scrolling tabs.
+ *
+ * **Parameter Sync System:**
+ * Sampling parameters (temperature, top_p, etc.) can come from three sources:
+ * 1. **Server Props**: Default values from `/props` endpoint
+ * 2. **User Custom**: Values explicitly set by user (overrides server)
+ * 3. **App Default**: Fallback when server props unavailable
+ *
+ * The `ChatSettingsParameterSourceIndicator` badge shows which source is active.
+ *
+ */
+
+/**
+ * **ChatSettings** - Application settings panel
+ *
+ * Comprehensive settings interface with categorized sections. Manages all
+ * user preferences and sampling parameters. Integrates with config store
+ * for persistence and ParameterSyncService for server synchronization.
+ *
+ * **Architecture:**
+ * - Uses tabbed navigation with category sections
+ * - Maintains local form state, commits on save
+ * - Tracks user overrides vs server defaults for sampling params
+ * - Exposes reset() method for dialog close without save
+ *
+ * **Categories:**
+ * - **General**: API key, system message, show system messages toggle
+ * - **Display**: Theme selection, message actions visibility, model info badge
+ * - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
+ * - **Penalties**: Frequency penalty, presence penalty, repeat last N
+ * - **Import/Export**: Conversation backup and restore
+ * - **Developer**: Debug options, disable auto-scroll
+ *
+ * **Parameter Sync:**
+ * - Fetches defaults from server `/props` endpoint
+ * - Shows source indicator badge (Custom/Server Props/Default)
+ * - Real-time badge updates as user types
+ * - Tracks which parameters user has explicitly overridden
+ *
+ * **Features:**
+ * - Mobile-responsive layout with horizontal scrolling tabs
+ * - Form validation with error messages
+ * - Secure API key storage (masked input)
+ * - Import/export conversations as JSON
+ * - Reset to defaults option per parameter
+ *
+ * **Exported API:**
+ * - `reset()` - Reset form fields to currently saved values (for cancel action)
+ *
+ * @example
+ * ```svelte
+ * <ChatSettings
+ * bind:this={settingsRef}
+ * onSave={() => dialogOpen = false}
+ * onCancel={() => { settingsRef.reset(); dialogOpen = false; }}
+ * />
+ * ```
+ */
+export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
+
+/**
+ * Footer with save/cancel buttons for settings panel. Positioned at bottom
+ * of settings dialog. Save button commits form state to config store,
+ * cancel button triggers reset and close.
+ */
+export { default as ChatSettingsFooter } from './ChatSettings/ChatSettingsFooter.svelte';
+
+/**
+ * Form fields renderer for individual settings. Generates appropriate input
+ * components based on field type (text, number, select, checkbox, textarea).
+ * Handles validation, help text display, and parameter source indicators.
+ */
+export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields.svelte';
+
+/**
+ * Import/export tab content for conversation data management. Provides buttons
+ * to export all conversations as JSON file and import from JSON file.
+ * Handles file download/upload and data validation.
+ */
+export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSettingsImportExportTab.svelte';
+
+/**
+ * Badge indicating parameter source for sampling settings. Shows one of:
+ * - **Custom**: User has explicitly set this value (orange badge)
+ * - **Server Props**: Using default from `/props` endpoint (blue badge)
+ * - **Default**: Using app default, server props unavailable (gray badge)
+ * Updates in real-time as user types to show immediate feedback.
+ */
+export { default as ChatSettingsParameterSourceIndicator } from './ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
+
+/**
+ *
+ * SIDEBAR
+ *
+ * The sidebar integrates with ShadCN's sidebar component system
+ * for consistent styling and mobile responsiveness.
+ * Conversations are loaded from conversationsStore and displayed in reverse
+ * chronological order (most recent first).
+ *
+ */
+
+/**
+ * **ChatSidebar** - Chat Sidebar with actions menu and conversation list
+ *
+ * Collapsible sidebar displaying conversation history with search and
+ * management actions. Integrates with ShadCN sidebar component for
+ * consistent styling and mobile responsiveness.
+ *
+ * **Architecture:**
+ * - Uses ShadCN Sidebar.* components for structure
+ * - Fetches conversations from conversationsStore
+ * - Manages search state and filtered results locally
+ * - Handles conversation CRUD operations via conversationsStore
+ *
+ * **Navigation:**
+ * - Click conversation to navigate to `/chat/[id]`
+ * - New chat button navigates to `/` (root)
+ * - Active conversation highlighted based on route params
+ *
+ * **Conversation Management:**
+ * - Right-click or menu button for context menu
+ * - Rename: Opens inline edit dialog
+ * - Delete: Shows confirmation with conversation preview
+ * - Delete All: Removes all conversations with confirmation
+ *
+ * **Features:**
+ * - Search/filter conversations by title
+ * - Conversation list with message previews (first message truncated)
+ * - Active conversation highlighting
+ * - Mobile-responsive collapse/expand via ShadCN sidebar
+ * - New chat button in header
+ * - Settings button opens DialogChatSettings
+ *
+ * **Exported API:**
+ * - `handleMobileSidebarItemClick()` - Close sidebar on mobile after item selection
+ * - `activateSearchMode()` - Focus search input programmatically
+ * - `editActiveConversation()` - Open rename dialog for current conversation
+ *
+ * @example
+ * ```svelte
+ * <ChatSidebar bind:this={sidebarRef} />
+ * ```
+ */
+export { default as ChatSidebar } from './ChatSidebar/ChatSidebar.svelte';
+
+/**
+ * Action buttons for sidebar header. Contains new chat button, settings button,
+ * and delete all conversations button. Manages dialog states for settings and
+ * delete confirmation.
+ */
+export { default as ChatSidebarActions } from './ChatSidebar/ChatSidebarActions.svelte';
+
+/**
+ * Single conversation item in sidebar. Displays conversation title (truncated),
+ * last message preview, and timestamp. Shows context menu on right-click with
+ * rename and delete options. Highlights when active (matches current route).
+ * Handles click to navigate and keyboard accessibility.
+ */
+export { default as ChatSidebarConversationItem } from './ChatSidebar/ChatSidebarConversationItem.svelte';
+
+/**
+ * Search input for filtering conversations in sidebar. Filters conversation
+ * list by title as user types. Shows clear button when query is not empty.
+ * Integrated into sidebar header with proper styling.
+ */
+export { default as ChatSidebarSearch } from './ChatSidebar/ChatSidebarSearch.svelte';
--- /dev/null
+/**
+ *
+ * DIALOGS
+ *
+ * Modal dialog components for the chat application.
+ *
+ * All dialogs use ShadCN Dialog or AlertDialog components for consistent
+ * styling, accessibility, and animation. They integrate with application
+ * stores for state management and data access.
+ *
+ */
+
+/**
+ *
+ * SETTINGS DIALOGS
+ *
+ * Dialogs for application and server configuration.
+ *
+ */
+
+/**
+ * **DialogChatSettings** - Settings dialog wrapper
+ *
+ * Modal dialog containing ChatSettings component with proper
+ * open/close state management and automatic form reset on open.
+ *
+ * **Architecture:**
+ * - Wraps ChatSettings component in ShadCN Dialog
+ * - Manages open/close state via bindable `open` prop
+ * - Resets form state when dialog opens to discard unsaved changes
+ *
+ * @example
+ * ```svelte
+ * <DialogChatSettings bind:open={showSettings} />
+ * ```
+ */
+export { default as DialogChatSettings } from './DialogChatSettings.svelte';
+
+/**
+ *
+ * CONFIRMATION DIALOGS
+ *
+ * Dialogs for user action confirmations. Use AlertDialog for blocking
+ * confirmations that require explicit user decision before proceeding.
+ *
+ */
+
+/**
+ * **DialogConfirmation** - Generic confirmation dialog
+ *
+ * Reusable confirmation dialog with customizable title, description,
+ * and action buttons. Supports destructive action styling and custom icons.
+ * Used for delete confirmations, irreversible actions, and important decisions.
+ *
+ * **Architecture:**
+ * - Uses ShadCN AlertDialog
+ * - Supports variant styling (default, destructive)
+ * - Customizable button labels and callbacks
+ *
+ * **Features:**
+ * - Customizable title and description text
+ * - Destructive variant with red styling for dangerous actions
+ * - Custom icon support in header
+ * - Cancel and confirm button callbacks
+ * - Keyboard accessible (Escape to cancel, Enter to confirm)
+ *
+ * @example
+ * ```svelte
+ * <DialogConfirmation
+ * bind:open={showDelete}
+ * title="Delete conversation?"
+ * description="This action cannot be undone."
+ * variant="destructive"
+ * onConfirm={handleDelete}
+ * onCancel={() => showDelete = false}
+ * />
+ * ```
+ */
+export { default as DialogConfirmation } from './DialogConfirmation.svelte';
+
+/**
+ * **DialogConversationTitleUpdate** - Conversation rename confirmation
+ *
+ * Confirmation dialog shown when editing the first user message in a conversation.
+ * Asks user whether to update the conversation title to match the new message content.
+ *
+ * **Architecture:**
+ * - Uses ShadCN AlertDialog
+ * - Shows current vs proposed title comparison
+ * - Triggered by ChatMessages when first message is edited
+ *
+ * **Features:**
+ * - Side-by-side display of current and new title
+ * - "Keep Current Title" and "Update Title" action buttons
+ * - Styled title previews in muted background boxes
+ *
+ * @example
+ * ```svelte
+ * <DialogConversationTitleUpdate
+ * bind:open={showTitleUpdate}
+ * currentTitle={conversation.name}
+ * newTitle={truncatedMessageContent}
+ * onConfirm={updateTitle}
+ * onCancel={() => showTitleUpdate = false}
+ * />
+ * ```
+ */
+export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte';
+
+/**
+ *
+ * CONTENT PREVIEW DIALOGS
+ *
+ * Dialogs for previewing and displaying content in full-screen or modal views.
+ *
+ */
+
+/**
+ * **DialogCodePreview** - Full-screen code/HTML preview
+ *
+ * Full-screen dialog for previewing HTML or code in an isolated iframe.
+ * Used by MarkdownContent component for previewing rendered HTML blocks
+ * from code blocks in chat messages.
+ *
+ * **Architecture:**
+ * - Uses ShadCN Dialog with full viewport layout
+ * - Sandboxed iframe execution (allow-scripts only)
+ * - Clears content when closed for security
+ *
+ * **Features:**
+ * - Full viewport iframe preview
+ * - Sandboxed execution environment
+ * - Close button with mix-blend-difference for visibility over any content
+ * - Automatic content cleanup on close
+ * - Supports HTML preview with proper isolation
+ *
+ * @example
+ * ```svelte
+ * <DialogCodePreview
+ * bind:open={showPreview}
+ * code={htmlContent}
+ * language="html"
+ * />
+ * ```
+ */
+export { default as DialogCodePreview } from './DialogCodePreview.svelte';
+
+/**
+ *
+ * ATTACHMENT DIALOGS
+ *
+ * Dialogs for viewing and managing file attachments. Support both
+ * uploaded files (pending) and stored attachments (in messages).
+ *
+ */
+
+/**
+ * **DialogChatAttachmentPreview** - Full-size attachment preview
+ *
+ * Modal dialog for viewing file attachments at full size. Supports different
+ * file types with appropriate preview modes: images, text files, PDFs, and audio.
+ *
+ * **Architecture:**
+ * - Wraps ChatAttachmentPreview component in ShadCN Dialog
+ * - Accepts either uploaded file or stored attachment as data source
+ * - Resets preview state when dialog opens
+ *
+ * **Features:**
+ * - Full-size image display with proper scaling
+ * - Text file content with syntax highlighting
+ * - PDF preview with text/image view toggle
+ * - Audio file placeholder with download option
+ * - File name and size display in header
+ * - Download button for all file types
+ * - Vision modality check for image attachments
+ *
+ * @example
+ * ```svelte
+ * <!-- Preview uploaded file -->
+ * <DialogChatAttachmentPreview
+ * bind:open={showPreview}
+ * uploadedFile={selectedFile}
+ * activeModelId={currentModel}
+ * />
+ *
+ * <!-- Preview stored attachment -->
+ * <DialogChatAttachmentPreview
+ * bind:open={showPreview}
+ * attachment={selectedAttachment}
+ * />
+ * ```
+ */
+export { default as DialogChatAttachmentPreview } from './DialogChatAttachmentPreview.svelte';
+
+/**
+ * **DialogChatAttachmentsViewAll** - Grid view of all attachments
+ *
+ * Dialog showing all attachments in a responsive grid layout. Triggered by
+ * "+X more" button in ChatAttachmentsList when there are too many attachments
+ * to display inline.
+ *
+ * **Architecture:**
+ * - Wraps ChatAttachmentsViewAll component in ShadCN Dialog
+ * - Supports both readonly (message view) and editable (form) modes
+ * - Displays total attachment count in header
+ *
+ * **Features:**
+ * - Responsive grid layout for all attachments
+ * - Thumbnail previews with click-to-expand
+ * - Remove button in editable mode
+ * - Configurable thumbnail dimensions
+ * - Vision modality validation for images
+ *
+ * @example
+ * ```svelte
+ * <DialogChatAttachmentsViewAll
+ * bind:open={showAllAttachments}
+ * attachments={message.extra}
+ * readonly
+ * />
+ * ```
+ */
+export { default as DialogChatAttachmentsViewAll } from './DialogChatAttachmentsViewAll.svelte';
+
+/**
+ *
+ * ERROR & ALERT DIALOGS
+ *
+ * Dialogs for displaying errors, warnings, and alerts to users.
+ * Provide context about what went wrong and recovery options.
+ *
+ */
+
+/**
+ * **DialogChatError** - Chat/generation error display
+ *
+ * Alert dialog for displaying chat and generation errors with context
+ * information. Supports different error types with appropriate styling
+ * and messaging.
+ *
+ * **Architecture:**
+ * - Uses ShadCN AlertDialog for modal display
+ * - Differentiates between timeout and server errors
+ * - Shows context info when available (token counts)
+ *
+ * **Error Types:**
+ * - **timeout**: TCP timeout with timer icon, red destructive styling
+ * - **server**: Server error with warning icon, amber warning styling
+ *
+ * **Features:**
+ * - Type-specific icons (TimerOff for timeout, AlertTriangle for server)
+ * - Error message display in styled badge
+ * - Context info showing prompt tokens and context size
+ * - Close button to dismiss
+ *
+ * @example
+ * ```svelte
+ * <DialogChatError
+ * bind:open={showError}
+ * type="server"
+ * message={errorMessage}
+ * contextInfo={{ n_prompt_tokens: 1024, n_ctx: 4096 }}
+ * />
+ * ```
+ */
+export { default as DialogChatError } from './DialogChatError.svelte';
+
+/**
+ * **DialogEmptyFileAlert** - Empty file upload warning
+ *
+ * Alert dialog shown when user attempts to upload empty files. Lists the
+ * empty files that were detected and removed from attachments, with
+ * explanation of why empty files cannot be processed.
+ *
+ * **Architecture:**
+ * - Uses ShadCN AlertDialog for modal display
+ * - Receives list of empty file names from ChatScreen
+ * - Triggered during file upload validation
+ *
+ * **Features:**
+ * - FileX icon indicating file error
+ * - List of empty file names in monospace font
+ * - Explanation of what happened and why
+ * - Single "Got it" dismiss button
+ *
+ * @example
+ * ```svelte
+ * <DialogEmptyFileAlert
+ * bind:open={showEmptyAlert}
+ * emptyFiles={['empty.txt', 'blank.md']}
+ * />
+ * ```
+ */
+export { default as DialogEmptyFileAlert } from './DialogEmptyFileAlert.svelte';
+
+/**
+ * **DialogModelNotAvailable** - Model unavailable error
+ *
+ * Alert dialog shown when the requested model (from URL params or selection)
+ * is not available on the server. Displays the requested model name and
+ * offers selection from available models.
+ *
+ * **Architecture:**
+ * - Uses ShadCN AlertDialog for modal display
+ * - Integrates with SvelteKit navigation for model switching
+ * - Receives available models list from modelsStore
+ *
+ * **Features:**
+ * - Warning icon with amber styling
+ * - Requested model name display in styled badge
+ * - Scrollable list of available models
+ * - Click model to navigate with updated URL params
+ * - Cancel button to dismiss without selection
+ *
+ * @example
+ * ```svelte
+ * <DialogModelNotAvailable
+ * bind:open={showModelError}
+ * modelName={requestedModel}
+ * availableModels={modelsList}
+ * />
+ * ```
+ */
+export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.svelte';
+
+/**
+ *
+ * DATA MANAGEMENT DIALOGS
+ *
+ * Dialogs for managing conversation data, including import/export
+ * and selection operations.
+ *
+ */
+
+/**
+ * **DialogConversationSelection** - Conversation picker for import/export
+ *
+ * Dialog for selecting conversations during import or export operations.
+ * Displays list of conversations with checkboxes for multi-selection.
+ * Used by ChatSettingsImportExportTab for data management.
+ *
+ * **Architecture:**
+ * - Wraps ConversationSelection component in ShadCN Dialog
+ * - Supports export mode (select from local) and import mode (select from file)
+ * - Resets selection state when dialog opens
+ * - High z-index to appear above settings dialog
+ *
+ * **Features:**
+ * - Multi-select with checkboxes
+ * - Conversation title and message count display
+ * - Select all / deselect all controls
+ * - Mode-specific descriptions (export vs import)
+ * - Cancel and confirm callbacks with selected conversations
+ *
+ * @example
+ * ```svelte
+ * <DialogConversationSelection
+ * bind:open={showExportSelection}
+ * conversations={allConversations}
+ * messageCountMap={messageCounts}
+ * mode="export"
+ * onConfirm={handleExport}
+ * onCancel={() => showExportSelection = false}
+ * />
+ * ```
+ */
+export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';
+
+/**
+ *
+ * MODEL INFORMATION DIALOGS
+ *
+ * Dialogs for displaying model and server information.
+ *
+ */
+
+/**
+ * **DialogModelInformation** - Model details display
+ *
+ * Dialog showing comprehensive information about the currently loaded model
+ * and server configuration. Displays model metadata, capabilities, and
+ * server settings in a structured table format.
+ *
+ * **Architecture:**
+ * - Uses ShadCN Dialog with wide layout for table display
+ * - Fetches data from serverStore (props) and modelsStore (metadata)
+ * - Auto-fetches models when dialog opens if not loaded
+ *
+ * **Information Displayed:**
+ * - **Model**: Name with copy button
+ * - **File Path**: Full path to model file with copy button
+ * - **Context Size**: Current context window size
+ * - **Training Context**: Original training context (if available)
+ * - **Model Size**: File size in human-readable format
+ * - **Parameters**: Parameter count (e.g., "7B", "70B")
+ * - **Embedding Size**: Embedding dimension
+ * - **Vocabulary Size**: Token vocabulary size
+ * - **Vocabulary Type**: Tokenizer type (BPE, etc.)
+ * - **Parallel Slots**: Number of concurrent request slots
+ * - **Modalities**: Supported input types (text, vision, audio)
+ * - **Build Info**: Server build information
+ * - **Chat Template**: Full Jinja template in scrollable code block
+ *
+ * **Features:**
+ * - Copy buttons for model name and path
+ * - Modality badges with icons
+ * - Responsive table layout with container queries
+ * - Loading state while fetching model info
+ * - Scrollable chat template display
+ *
+ * @example
+ * ```svelte
+ * <DialogModelInformation bind:open={showModelInfo} />
+ * ```
+ */
+export { default as DialogModelInformation } from './DialogModelInformation.svelte';
export * from './actions';
export * from './badges';
+export * from './chat';
export * from './content';
+export * from './dialogs';
export * from './forms';
export * from './misc';
export * from './models';
export * from './navigation';
export * from './server';
-
-// Chat
-export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
-export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
-export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
-export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
-export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
-export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
-export { default as ChatFormActionAttachmentsDropdown } from './chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
-export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
-export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
-export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
-export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
-export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
-export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
-export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
-export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
-export { default as ChatMessageAssistant } from './chat/ChatMessages/ChatMessageAssistant.svelte';
-export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-export { default as ChatMessageEditForm } from './chat/ChatMessages/ChatMessageEditForm.svelte';
-export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
-export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
-export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
-export { default as ChatMessageUser } from './chat/ChatMessages/ChatMessageUser.svelte';
-export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
-export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
-export { default as ChatScreenDragOverlay } from './chat/ChatScreen/ChatScreenDragOverlay.svelte';
-export { default as ChatScreenForm } from './chat/ChatScreen/ChatScreenForm.svelte';
-export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
-export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
-export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
-export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
-export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
-export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
-export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
-export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
-export { default as ChatSidebarActions } from './chat/ChatSidebar/ChatSidebarActions.svelte';
-export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
-export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
-
-// Dialogs
-export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
-export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
-export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
-export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
-export { default as DialogCodePreview } from './dialogs/DialogCodePreview.svelte';
-export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
-export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
-export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
-export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
-export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
-export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
-
-// Compatibility aliases
-export { default as ActionButton } from './actions/ActionIcon.svelte';
-export { default as ActionDropdown } from './navigation/DropdownMenuActions.svelte';
-export { default as CopyToClipboardIcon } from './actions/ActionIconCopyToClipboard.svelte';
-export { default as RemoveButton } from './actions/ActionIconRemove.svelte';
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
- /** Optional compatibility prop for context-aware selectors. */
- upToMessageId?: string;
}
let {
onModelChange,
disabled = false,
forceForegroundText = false,
- useGlobalSelection = false,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- upToMessageId: _upToMessageId = undefined
+ useGlobalSelection = false
}: Props = $props();
let options = $derived(modelOptions());
--- /dev/null
+// Agentic tool call tag markers
+export const AGENTIC_TAGS = {
+ TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
+ TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
+ TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
+ TOOL_ARGS_START: '<<<TOOL_ARGS_START>>>',
+ TOOL_ARGS_END: '<<<TOOL_ARGS_END>>>',
+ TAG_SUFFIX: '>>>'
+} as const;
+
+export const REASONING_TAGS = {
+ START: '<<<reasoning_content_start>>>',
+ END: '<<<reasoning_content_end>>>'
+} as const;
+
+// Regex patterns for parsing agentic content
+export const AGENTIC_REGEX = {
+ // Matches completed tool calls (with END marker)
+ COMPLETED_TOOL_CALL:
+ /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
+ // Matches pending tool call (has NAME and ARGS but no END)
+ PENDING_TOOL_CALL:
+ /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*)$/,
+ // Matches partial tool call (has START and NAME, ARGS still streaming)
+ PARTIAL_WITH_NAME:
+ /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*)$/,
+ // Matches early tool call (just START marker)
+ EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
+ // Matches partial marker at end of content
+ PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
+ // Matches reasoning content blocks (including tags)
+ REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
+ // Matches an opening reasoning tag and any remaining content (unterminated)
+ REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
+ // Matches tool name inside content
+ TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
+} as const;
--- /dev/null
+export const ATTACHMENT_LABEL_FILE = 'File';
+export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
*/
/**
- * Default TTL (Time-To-Live) for cache entries in milliseconds.
+ * Default TTL (Time-To-Live) for cache entries in milliseconds
+ * @default 5 minutes
*/
export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
/**
- * Default maximum number of entries in a cache.
+ * Default maximum number of entries in a cache
+ * @default 100
*/
export const DEFAULT_CACHE_MAX_ENTRIES = 100;
/**
- * TTL for model props cache in milliseconds.
+ * TTL for model props cache in milliseconds
+ * Props don't change frequently, so we can cache them longer
+ * @default 10 minutes
*/
export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
/**
- * Maximum number of model props to cache.
+ * Maximum number of model props to cache
+ * @default 50
*/
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
/**
- * Maximum number of inactive conversation states to keep in memory.
+ * Maximum number of inactive conversation states to keep in memory
+ * States for conversations beyond this limit will be cleaned up
+ * @default 10
*/
export const MAX_INACTIVE_CONVERSATION_STATES = 10;
/**
- * Maximum age (in ms) for inactive conversation states before cleanup.
+ * Maximum age (in ms) for inactive conversation states before cleanup
+ * States older than this will be removed during cleanup
+ * @default 30 minutes
*/
export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000;
+++ /dev/null
-export const DEFAULT_CONTEXT = 4096;
+++ /dev/null
-export { INPUT_CLASSES } from './css-classes';
+import { ColorMode } from '$lib/enums/ui';
+import { Monitor, Moon, Sun } from '@lucide/svelte';
+
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
apiKey: '',
systemMessage: '',
showSystemMessage: true,
- theme: 'system',
+ theme: ColorMode.SYSTEM,
showThoughtInProgress: false,
- showToolCalls: false,
disableReasoningParsing: false,
showRawOutputSwitch: false,
keepStatsVisible: false,
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
showThoughtInProgress: 'Expand thought process by default when generating messages.',
- showToolCalls:
- 'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
disableReasoningParsing:
'Send reasoning_format=none to prevent server-side extraction of reasoning tokens into separate field',
showRawOutputSwitch:
enableContinueGeneration:
'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
};
+
+export const SETTINGS_COLOR_MODES_CONFIG = [
+ { value: ColorMode.SYSTEM, label: 'System', icon: Monitor },
+ { value: ColorMode.LIGHT, label: 'Light', icon: Sun },
+ { value: ColorMode.DARK, label: 'Dark', icon: Moon }
+];
--- /dev/null
+/**
+ * Settings key constants for ChatSettings configuration.
+ *
+ * These keys correspond to properties in SettingsConfigType and are used
+ * in settings field configurations to ensure consistency.
+ */
+export const SETTINGS_KEYS = {
+ // General
+ THEME: 'theme',
+ API_KEY: 'apiKey',
+ SYSTEM_MESSAGE: 'systemMessage',
+ PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
+ COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
+ ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
+ PDF_AS_IMAGE: 'pdfAsImage',
+ ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
+ // Display
+ SHOW_MESSAGE_STATS: 'showMessageStats',
+ SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
+ KEEP_STATS_VISIBLE: 'keepStatsVisible',
+ AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
+ RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
+ DISABLE_AUTO_SCROLL: 'disableAutoScroll',
+ ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
+ AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
+ // Sampling
+ TEMPERATURE: 'temperature',
+ DYNATEMP_RANGE: 'dynatemp_range',
+ DYNATEMP_EXPONENT: 'dynatemp_exponent',
+ TOP_K: 'top_k',
+ TOP_P: 'top_p',
+ MIN_P: 'min_p',
+ XTC_PROBABILITY: 'xtc_probability',
+ XTC_THRESHOLD: 'xtc_threshold',
+ TYP_P: 'typ_p',
+ MAX_TOKENS: 'max_tokens',
+ SAMPLERS: 'samplers',
+ BACKEND_SAMPLING: 'backend_sampling',
+ // Penalties
+ REPEAT_LAST_N: 'repeat_last_n',
+ REPEAT_PENALTY: 'repeat_penalty',
+ PRESENCE_PENALTY: 'presence_penalty',
+ FREQUENCY_PENALTY: 'frequency_penalty',
+ DRY_MULTIPLIER: 'dry_multiplier',
+ DRY_BASE: 'dry_base',
+ DRY_ALLOWED_LENGTH: 'dry_allowed_length',
+ DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
+ // Developer
+ DISABLE_REASONING_PARSING: 'disableReasoningParsing',
+ SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
+ CUSTOM: 'custom'
+} as const;
CS = '.cs'
}
+// MIME type prefixes and includes for content detection
+export enum MimeTypePrefix {
+ IMAGE = 'image/',
+ TEXT = 'text'
+}
+
+export enum MimeTypeIncludes {
+ JSON = 'json',
+ JAVASCRIPT = 'javascript',
+ TYPESCRIPT = 'typescript'
+}
+
+// URI patterns for content detection
+export enum UriPattern {
+ DATABASE_KEYWORD = 'database',
+ DATABASE_SCHEME = 'db://'
+}
+
// MIME type enums
export enum MimeTypeApplication {
- PDF = 'application/pdf'
+ PDF = 'application/pdf',
+ OCTET_STREAM = 'application/octet-stream'
}
export enum MimeTypeAudio {
export enum MimeTypeImage {
JPEG = 'image/jpeg',
+ JPG = 'image/jpg',
PNG = 'image/png',
GIF = 'image/gif',
WEBP = 'image/webp',
export {
ChatMessageStatsView,
- ReasoningFormat,
+ ContentPartType,
+ ErrorDialogType,
MessageRole,
MessageType,
- ContentPartType,
- ErrorDialogType
+ ReasoningFormat
} from './chat';
export {
FileExtensionAudio,
FileExtensionPdf,
FileExtensionText,
+ MimeTypePrefix,
+ MimeTypeIncludes,
+ UriPattern,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeImage,
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
-export { KeyboardKey } from './keyboard';
+export { ColorMode, UrlPrefix } from './ui';
-export { UrlPrefix } from './ui';
+export { KeyboardKey } from './keyboard';
+export enum ColorMode {
+ LIGHT = 'light',
+ DARK = 'dark',
+ SYSTEM = 'system'
+}
+
/**
- * URL prefixes for protocol detection.
+ * URL prefixes for protocol detection
*/
export enum UrlPrefix {
DATA = 'data:',
+++ /dev/null
-import { modelsStore } from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
-import { toast } from 'svelte-sonner';
-import type { ModelModalities } from '$lib/types';
-
-interface UseModelChangeValidationOptions {
- /**
- * Function to get required modalities for validation.
- */
- getRequiredModalities: () => ModelModalities;
-
- /**
- * Optional callback to execute after successful validation.
- */
- onSuccess?: (modelName: string) => void;
-
- /**
- * Optional callback for rollback on validation failure.
- */
- onValidationFailure?: (previousModelId: string | null) => Promise<void>;
-}
-
-export function useModelChangeValidation(options: UseModelChangeValidationOptions) {
- const { getRequiredModalities, onSuccess, onValidationFailure } = options;
-
- let previousSelectedModelId: string | null = null;
- const isRouter = $derived(isRouterMode());
-
- async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
- try {
- if (onValidationFailure) {
- previousSelectedModelId = modelsStore.selectedModelId;
- }
-
- let hasLoadedModel = false;
- const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
-
- if (isRouter && !isModelLoadedBefore) {
- try {
- await modelsStore.loadModel(modelName);
- hasLoadedModel = true;
- } catch {
- toast.error(`Failed to load model "${modelName}"`);
- return false;
- }
- }
-
- const props = await modelsStore.fetchModelProps(modelName);
-
- if (props?.modalities) {
- const requiredModalities = getRequiredModalities();
-
- const missingModalities: string[] = [];
- if (requiredModalities.vision && !props.modalities.vision) {
- missingModalities.push('vision');
- }
- if (requiredModalities.audio && !props.modalities.audio) {
- missingModalities.push('audio');
- }
-
- if (missingModalities.length > 0) {
- toast.error(
- `Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
- );
-
- if (isRouter && hasLoadedModel) {
- try {
- await modelsStore.unloadModel(modelName);
- } catch (error) {
- console.error('Failed to unload incompatible model:', error);
- }
- }
-
- if (onValidationFailure && previousSelectedModelId) {
- await onValidationFailure(previousSelectedModelId);
- }
-
- return false;
- }
- }
-
- await modelsStore.selectModelById(modelId);
-
- if (onSuccess) {
- onSuccess(modelName);
- }
-
- return true;
- } catch (error) {
- console.error('Failed to change model:', error);
- toast.error('Failed to validate model capabilities');
-
- if (onValidationFailure && previousSelectedModelId) {
- await onValidationFailure(previousSelectedModelId);
- }
-
- return false;
- }
- }
-
- return {
- handleModelChange
- };
-}
--- /dev/null
+import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
+import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels';
+import {
+ AttachmentType,
+ ContentPartType,
+ MessageRole,
+ ReasoningFormat,
+ UrlPrefix
+} from '$lib/enums';
+import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { AGENTIC_REGEX } from '$lib/constants/agentic';
+
+export class ChatService {
+ private static stripReasoningContent(
+ content: ApiChatMessageData['content'] | null | undefined
+ ): ApiChatMessageData['content'] | null | undefined {
+ if (!content) {
+ return content;
+ }
+
+ if (typeof content === 'string') {
+ return content
+ .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
+ .replace(AGENTIC_REGEX.REASONING_OPEN, '');
+ }
+
+ if (!Array.isArray(content)) {
+ return content;
+ }
+
+ return content.map((part: ApiChatMessageContentPart) => {
+ if (part.type !== ContentPartType.TEXT || !part.text) return part;
+ return {
+ ...part,
+ text: part.text
+ .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
+ .replace(AGENTIC_REGEX.REASONING_OPEN, '')
+ };
+ });
+ }
+
+ /**
+ *
+ *
+ * Messaging
+ *
+ *
+ */
+
+ /**
+ * Sends a chat completion request to the llama.cpp server.
+ * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
+ * Automatically converts database messages with attachments to the appropriate API format.
+ *
+ * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments)
+ * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details.
+ * @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
+ * @throws {Error} if the request fails or is aborted
+ */
+ static async sendMessage(
+ messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
+ options: SettingsChatServiceOptions = {},
+ conversationId?: string,
+ signal?: AbortSignal
+ ): Promise<string | void> {
+ const {
+ stream,
+ onChunk,
+ onComplete,
+ onError,
+ onReasoningChunk,
+ onToolCallChunk,
+ onModel,
+ onTimings,
+ // Tools for function calling
+ tools,
+ // Generation parameters
+ temperature,
+ max_tokens,
+ // Sampling parameters
+ dynatemp_range,
+ dynatemp_exponent,
+ top_k,
+ top_p,
+ min_p,
+ xtc_probability,
+ xtc_threshold,
+ typ_p,
+ // Penalty parameters
+ repeat_last_n,
+ repeat_penalty,
+ presence_penalty,
+ frequency_penalty,
+ dry_multiplier,
+ dry_base,
+ dry_allowed_length,
+ dry_penalty_last_n,
+ // Other parameters
+ samplers,
+ backend_sampling,
+ custom,
+ timings_per_token,
+ // Config options
+ disableReasoningParsing
+ } = options;
+
+ const normalizedMessages: ApiChatMessageData[] = messages
+ .map((msg) => {
+ if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
+ const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
+
+ return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
+ } else {
+ return msg as ApiChatMessageData;
+ }
+ })
+ .filter((msg) => {
+ // Filter out empty system messages
+ if (msg.role === MessageRole.SYSTEM) {
+ const content = typeof msg.content === 'string' ? msg.content : '';
+
+ return content.trim().length > 0;
+ }
+
+ return true;
+ });
+
+ // Filter out image attachments if the model doesn't support vision
+ if (options.model && !modelsStore.modelSupportsVision(options.model)) {
+ normalizedMessages.forEach((msg) => {
+ if (Array.isArray(msg.content)) {
+ msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
+ if (part.type === ContentPartType.IMAGE_URL) {
+ console.info(
+ `[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
+ );
+
+ return false;
+ }
+
+ return true;
+ });
+ // If only text remains and it's a single part, simplify to string
+ if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
+ msg.content = msg.content[0].text;
+ }
+ }
+ });
+ }
+
+ const requestBody: ApiChatCompletionRequest = {
+ messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
+ role: msg.role,
+ // Strip reasoning tags/content from the prompt to avoid polluting KV cache.
+ // TODO: investigate backend expectations for reasoning tags and add a toggle if needed.
+ content: ChatService.stripReasoningContent(msg.content),
+ tool_calls: msg.tool_calls,
+ tool_call_id: msg.tool_call_id
+ })),
+ stream,
+ return_progress: stream ? true : undefined,
+ tools: tools && tools.length > 0 ? tools : undefined
+ };
+
+ // Include model in request if provided (required in ROUTER mode)
+ if (options.model) {
+ requestBody.model = options.model;
+ }
+
+ requestBody.reasoning_format = disableReasoningParsing
+ ? ReasoningFormat.NONE
+ : ReasoningFormat.AUTO;
+
+ if (temperature !== undefined) requestBody.temperature = temperature;
+ if (max_tokens !== undefined) {
+ // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
+ requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
+ }
+
+ if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
+ if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
+ if (top_k !== undefined) requestBody.top_k = top_k;
+ if (top_p !== undefined) requestBody.top_p = top_p;
+ if (min_p !== undefined) requestBody.min_p = min_p;
+ if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
+ if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
+ if (typ_p !== undefined) requestBody.typ_p = typ_p;
+
+ if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
+ if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
+ if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
+ if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
+ if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
+ if (dry_base !== undefined) requestBody.dry_base = dry_base;
+ if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
+ if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
+
+ if (samplers !== undefined) {
+ requestBody.samplers =
+ typeof samplers === 'string'
+ ? samplers.split(';').filter((s: string) => s.trim())
+ : samplers;
+ }
+
+ if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
+
+ if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
+
+ if (custom) {
+ try {
+ const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
+ Object.assign(requestBody, customParams);
+ } catch (error) {
+ console.warn('Failed to parse custom parameters:', error);
+ }
+ }
+
+ try {
+ const response = await fetch(`./v1/chat/completions`, {
+ method: 'POST',
+ headers: getJsonHeaders(),
+ body: JSON.stringify(requestBody),
+ signal
+ });
+
+ if (!response.ok) {
+ const error = await ChatService.parseErrorResponse(response);
+
+ if (onError) {
+ onError(error);
+ }
+
+ throw error;
+ }
+
+ if (stream) {
+ await ChatService.handleStreamResponse(
+ response,
+ onChunk,
+ onComplete,
+ onError,
+ onReasoningChunk,
+ onToolCallChunk,
+ onModel,
+ onTimings,
+ conversationId,
+ signal
+ );
+
+ return;
+ } else {
+ return ChatService.handleNonStreamResponse(
+ response,
+ onComplete,
+ onError,
+ onToolCallChunk,
+ onModel
+ );
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ console.log('Chat completion request was aborted');
+ return;
+ }
+
+ let userFriendlyError: Error;
+
+ if (error instanceof Error) {
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
+ userFriendlyError = new Error(
+ 'Unable to connect to server - please check if the server is running'
+ );
+ userFriendlyError.name = 'NetworkError';
+ } else if (error.message.includes('ECONNREFUSED')) {
+ userFriendlyError = new Error('Connection refused - server may be offline');
+ userFriendlyError.name = 'NetworkError';
+ } else if (error.message.includes('ETIMEDOUT')) {
+ userFriendlyError = new Error('Request timed out - the server took too long to respond');
+ userFriendlyError.name = 'TimeoutError';
+ } else {
+ userFriendlyError = error;
+ }
+ } else {
+ userFriendlyError = new Error('Unknown error occurred while sending message');
+ }
+
+ console.error('Error in sendMessage:', error);
+
+ if (onError) {
+ onError(userFriendlyError);
+ }
+
+ throw userFriendlyError;
+ }
+ }
+
+ /**
+ *
+ *
+ * Streaming
+ *
+ *
+ */
+
+ /**
+ * Handles streaming response from the chat completion API
+ * @param response - The Response object from the fetch request
+ * @param onChunk - Optional callback invoked for each content chunk received
+ * @param onComplete - Optional callback invoked when the stream is complete with full response
+ * @param onError - Optional callback invoked if an error occurs during streaming
+ * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
+ * @param conversationId - Optional conversation ID for per-conversation state tracking
+ * @returns {Promise<void>} Promise that resolves when streaming is complete
+ * @throws {Error} if the stream cannot be read or parsed
+ */
+ private static async handleStreamResponse(
+ response: Response,
+ onChunk?: (chunk: string) => void,
+ onComplete?: (
+ response: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCalls?: string
+ ) => void,
+ onError?: (error: Error) => void,
+ onReasoningChunk?: (chunk: string) => void,
+ onToolCallChunk?: (chunk: string) => void,
+ onModel?: (model: string) => void,
+ onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
+ conversationId?: string,
+ abortSignal?: AbortSignal
+ ): Promise<void> {
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response body');
+ }
+
+ const decoder = new TextDecoder();
+ let aggregatedContent = '';
+ let fullReasoningContent = '';
+ let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
+ let lastTimings: ChatMessageTimings | undefined;
+ let streamFinished = false;
+ let modelEmitted = false;
+ let toolCallIndexOffset = 0;
+ let hasOpenToolCallBatch = false;
+
+ const finalizeOpenToolCallBatch = () => {
+ if (!hasOpenToolCallBatch) {
+ return;
+ }
+
+ toolCallIndexOffset = aggregatedToolCalls.length;
+ hasOpenToolCallBatch = false;
+ };
+
+ const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
+ if (!toolCalls || toolCalls.length === 0) {
+ return;
+ }
+
+ aggregatedToolCalls = ChatService.mergeToolCallDeltas(
+ aggregatedToolCalls,
+ toolCalls,
+ toolCallIndexOffset
+ );
+
+ if (aggregatedToolCalls.length === 0) {
+ return;
+ }
+
+ hasOpenToolCallBatch = true;
+
+ const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
+
+ if (import.meta.env.DEV) {
+ console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
+ }
+
+ if (!serializedToolCalls) {
+ return;
+ }
+
+ if (!abortSignal?.aborted) {
+ onToolCallChunk?.(serializedToolCalls);
+ }
+ };
+
+ try {
+ let chunk = '';
+ while (true) {
+ if (abortSignal?.aborted) break;
+
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ if (abortSignal?.aborted) break;
+
+ chunk += decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n');
+ chunk = lines.pop() || '';
+
+ for (const line of lines) {
+ if (abortSignal?.aborted) break;
+
+ if (line.startsWith(UrlPrefix.DATA)) {
+ const data = line.slice(6);
+ if (data === '[DONE]') {
+ streamFinished = true;
+
+ continue;
+ }
+
+ try {
+ const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
+ const content = parsed.choices[0]?.delta?.content;
+ const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
+ const toolCalls = parsed.choices[0]?.delta?.tool_calls;
+ const timings = parsed.timings;
+ const promptProgress = parsed.prompt_progress;
+
+ const chunkModel = ChatService.extractModelName(parsed);
+ if (chunkModel && !modelEmitted) {
+ modelEmitted = true;
+ onModel?.(chunkModel);
+ }
+
+ if (promptProgress) {
+ ChatService.notifyTimings(undefined, promptProgress, onTimings);
+ }
+
+ if (timings) {
+ ChatService.notifyTimings(timings, promptProgress, onTimings);
+ lastTimings = timings;
+ }
+
+ if (content) {
+ finalizeOpenToolCallBatch();
+ aggregatedContent += content;
+ if (!abortSignal?.aborted) {
+ onChunk?.(content);
+ }
+ }
+
+ if (reasoningContent) {
+ finalizeOpenToolCallBatch();
+ fullReasoningContent += reasoningContent;
+ if (!abortSignal?.aborted) {
+ onReasoningChunk?.(reasoningContent);
+ }
+ }
+
+ processToolCallDelta(toolCalls);
+ } catch (e) {
+ console.error('Error parsing JSON chunk:', e);
+ }
+ }
+ }
+
+ if (abortSignal?.aborted) break;
+ }
+
+ if (abortSignal?.aborted) return;
+
+ if (streamFinished) {
+ finalizeOpenToolCallBatch();
+
+ const finalToolCalls =
+ aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
+
+ onComplete?.(
+ aggregatedContent,
+ fullReasoningContent || undefined,
+ lastTimings,
+ finalToolCalls
+ );
+ }
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('Stream error');
+
+ onError?.(err);
+
+ throw err;
+ } finally {
+ reader.releaseLock();
+ }
+ }
+
+ /**
+ * Handles non-streaming response from the chat completion API.
+ * Parses the JSON response and extracts the generated content.
+ *
+ * @param response - The fetch Response object containing the JSON data
+ * @param onComplete - Optional callback invoked when response is successfully parsed
+ * @param onError - Optional callback invoked if an error occurs during parsing
+ * @returns {Promise<string>} Promise that resolves to the generated content string
+ * @throws {Error} if the response cannot be parsed or is malformed
+ */
+ private static async handleNonStreamResponse(
+ response: Response,
+ onComplete?: (
+ response: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCalls?: string
+ ) => void,
+ onError?: (error: Error) => void,
+ onToolCallChunk?: (chunk: string) => void,
+ onModel?: (model: string) => void
+ ): Promise<string> {
+ try {
+ const responseText = await response.text();
+
+ if (!responseText.trim()) {
+ const noResponseError = new Error('No response received from server. Please try again.');
+
+ throw noResponseError;
+ }
+
+ const data: ApiChatCompletionResponse = JSON.parse(responseText);
+
+ const responseModel = ChatService.extractModelName(data);
+ if (responseModel) {
+ onModel?.(responseModel);
+ }
+
+ const content = data.choices[0]?.message?.content || '';
+ const reasoningContent = data.choices[0]?.message?.reasoning_content;
+ const toolCalls = data.choices[0]?.message?.tool_calls;
+
+ let serializedToolCalls: string | undefined;
+
+ if (toolCalls && toolCalls.length > 0) {
+ const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
+
+ if (mergedToolCalls.length > 0) {
+ serializedToolCalls = JSON.stringify(mergedToolCalls);
+ if (serializedToolCalls) {
+ onToolCallChunk?.(serializedToolCalls);
+ }
+ }
+ }
+
+ if (!content.trim() && !serializedToolCalls) {
+ const noResponseError = new Error('No response received from server. Please try again.');
+
+ throw noResponseError;
+ }
+
+ onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
+
+ return content;
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('Parse error');
+
+ onError?.(err);
+
+ throw err;
+ }
+ }
+
+ /**
+ * Merges tool call deltas into an existing array of tool calls.
+ * Handles both existing and new tool calls, updating existing ones and adding new ones.
+ *
+ * @param existing - The existing array of tool calls to merge into
+ * @param deltas - The array of tool call deltas to merge
+ * @param indexOffset - Optional offset to apply to the index of new tool calls
+ * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
+ */
+ private static mergeToolCallDeltas(
+ existing: ApiChatCompletionToolCall[],
+ deltas: ApiChatCompletionToolCallDelta[],
+ indexOffset = 0
+ ): ApiChatCompletionToolCall[] {
+ const result = existing.map((call) => ({
+ ...call,
+ function: call.function ? { ...call.function } : undefined
+ }));
+
+ for (const delta of deltas) {
+ const index =
+ typeof delta.index === 'number' && delta.index >= 0
+ ? delta.index + indexOffset
+ : result.length;
+
+ while (result.length <= index) {
+ result.push({ function: undefined });
+ }
+
+ const target = result[index]!;
+
+ if (delta.id) {
+ target.id = delta.id;
+ }
+
+ if (delta.type) {
+ target.type = delta.type;
+ }
+
+ if (delta.function) {
+ const fn = target.function ? { ...target.function } : {};
+
+ if (delta.function.name) {
+ fn.name = delta.function.name;
+ }
+
+ if (delta.function.arguments) {
+ fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
+ }
+
+ target.function = fn;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ *
+ *
+ * Conversion
+ *
+ *
+ */
+
+ /**
+ * Converts a database message with attachments to API chat message format.
+ * Processes various attachment types (images, text files, PDFs) and formats them
+ * as content parts suitable for the chat completion API.
+ *
+ * @param message - Database message object with optional extra attachments
+ * @param message.content - The text content of the message
+ * @param message.role - The role of the message sender (user, assistant, system)
+ * @param message.extra - Optional array of message attachments (images, files, etc.)
+ * @returns {ApiChatMessageData} object formatted for the chat completion API
+ * @static
+ */
+ static convertDbMessageToApiChatMessageData(
+ message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
+ ): ApiChatMessageData {
+ // Handle tool result messages (role: 'tool')
+ if (message.role === MessageRole.TOOL && message.toolCallId) {
+ return {
+ role: MessageRole.TOOL,
+ content: message.content,
+ tool_call_id: message.toolCallId
+ };
+ }
+
+ // Parse tool calls for assistant messages
+ let toolCalls: ApiChatCompletionToolCall[] | undefined;
+ if (message.toolCalls) {
+ try {
+ toolCalls = JSON.parse(message.toolCalls);
+ } catch {
+ // Ignore parse errors for malformed tool calls
+ }
+ }
+
+ if (!message.extra || message.extra.length === 0) {
+ const result: ApiChatMessageData = {
+ role: message.role as MessageRole,
+ content: message.content
+ };
+
+ if (toolCalls && toolCalls.length > 0) {
+ result.tool_calls = toolCalls;
+ }
+
+ return result;
+ }
+
+ const contentParts: ApiChatMessageContentPart[] = [];
+
+ if (message.content) {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: message.content
+ });
+ }
+
+ // Include images from all messages
+ const imageFiles = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
+ extra.type === AttachmentType.IMAGE
+ );
+
+ for (const image of imageFiles) {
+ contentParts.push({
+ type: ContentPartType.IMAGE_URL,
+ image_url: { url: image.base64Url }
+ });
+ }
+
+ const textFiles = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
+ extra.type === AttachmentType.TEXT
+ );
+
+ for (const textFile of textFiles) {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: formatAttachmentText('File', textFile.name, textFile.content)
+ });
+ }
+
+ // Handle legacy 'context' type from old webui (pasted content)
+ const legacyContextFiles = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
+ extra.type === AttachmentType.LEGACY_CONTEXT
+ );
+
+ for (const legacyContextFile of legacyContextFiles) {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
+ });
+ }
+
+ const audioFiles = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
+ extra.type === AttachmentType.AUDIO
+ );
+
+ for (const audio of audioFiles) {
+ contentParts.push({
+ type: ContentPartType.INPUT_AUDIO,
+ input_audio: {
+ data: audio.base64Data,
+ format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
+ }
+ });
+ }
+
+ const pdfFiles = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
+ extra.type === AttachmentType.PDF
+ );
+
+ for (const pdfFile of pdfFiles) {
+ if (pdfFile.processedAsImages && pdfFile.images) {
+ for (let i = 0; i < pdfFile.images.length; i++) {
+ contentParts.push({
+ type: ContentPartType.IMAGE_URL,
+ image_url: { url: pdfFile.images[i] }
+ });
+ }
+ } else {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
+ });
+ }
+ }
+
+ const result: ApiChatMessageData = {
+ role: message.role as MessageRole,
+ content: contentParts
+ };
+
+ return result;
+ }
+
+ /**
+ *
+ *
+ * Utilities
+ *
+ *
+ */
+
+ /**
+ * Parses error response and creates appropriate error with context information
+ * @param response - HTTP response object
+ * @returns Promise<Error> - Parsed error with context info if available
+ */
+ private static async parseErrorResponse(
+ response: Response
+ ): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
+ try {
+ const errorText = await response.text();
+ const errorData: ApiErrorResponse = JSON.parse(errorText);
+
+ const message = errorData.error?.message || 'Unknown server error';
+ const error = new Error(message) as Error & {
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+ };
+ error.name = response.status === 400 ? 'ServerError' : 'HttpError';
+
+ if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
+ error.contextInfo = {
+ n_prompt_tokens: errorData.error.n_prompt_tokens,
+ n_ctx: errorData.error.n_ctx
+ };
+ }
+
+ return error;
+ } catch {
+ const fallback = new Error(
+ `Server error (${response.status}): ${response.statusText}`
+ ) as Error & {
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+ };
+ fallback.name = 'HttpError';
+
+ return fallback;
+ }
+ }
+
+ /**
+ * Extracts model name from Chat Completions API response data.
+ * Handles various response formats including streaming chunks and final responses.
+ *
+ * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
+ * in the response. We override it with the actual model name from serverStore.
+ *
+ * @param data - Raw response data from the Chat Completions API
+ * @returns Model name string if found, undefined otherwise
+ * @private
+ */
+ private static extractModelName(data: unknown): string | undefined {
+ const asRecord = (value: unknown): Record<string, unknown> | undefined => {
+ return typeof value === 'object' && value !== null
+ ? (value as Record<string, unknown>)
+ : undefined;
+ };
+
+ const getTrimmedString = (value: unknown): string | undefined => {
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
+ };
+
+ const root = asRecord(data);
+ if (!root) return undefined;
+
+ // 1) root (some implementations provide `model` at the top level)
+ const rootModel = getTrimmedString(root.model);
+ if (rootModel) {
+ return rootModel;
+ }
+
+ // 2) streaming choice (delta) or final response (message)
+ const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
+ if (!firstChoice) {
+ return undefined;
+ }
+
+ // priority: delta.model (first chunk) else message.model (final response)
+ const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
+ if (deltaModel) {
+ return deltaModel;
+ }
+
+ const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
+ if (messageModel) {
+ return messageModel;
+ }
+
+ // avoid guessing from non-standard locations (metadata, etc.)
+ return undefined;
+ }
+
+ /**
+ * Calls the onTimings callback with timing data from streaming response.
+ *
+ * @param timings - Timing information from the Chat Completions API response
+ * @param promptProgress - Prompt processing progress data
+ * @param onTimingsCallback - Callback function to invoke with timing data
+ * @private
+ */
+ private static notifyTimings(
+ timings: ChatMessageTimings | undefined,
+ promptProgress: ChatMessagePromptProgress | undefined,
+ onTimingsCallback:
+ | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
+ | undefined
+ ): void {
+ if (!onTimingsCallback || (!timings && !promptProgress)) return;
+
+ onTimingsCallback(timings, promptProgress);
+ }
+}
+++ /dev/null
-import { getJsonHeaders } from '$lib/utils';
-import { AttachmentType } from '$lib/enums';
-
-/**
- * ChatService - Low-level API communication layer for Chat Completions
- *
- * **Terminology - Chat vs Conversation:**
- * - **Chat**: The active interaction space with the Chat Completions API. This service
- * handles the real-time communication with the AI backend - sending messages, receiving
- * streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused.
- * - **Conversation**: The persistent database entity storing all messages and metadata.
- * Managed by ConversationsService/Store, conversations persist across sessions.
- *
- * This service handles direct communication with the llama-server's Chat Completions API.
- * It provides the network layer abstraction for AI model interactions while remaining
- * stateless and focused purely on API communication.
- *
- * **Architecture & Relationships:**
- * - **ChatService** (this class): Stateless API communication layer
- * - Handles HTTP requests/responses with the llama-server
- * - Manages streaming and non-streaming response parsing
- * - Provides per-conversation request abortion capabilities
- * - Converts database messages to API format
- * - Handles error translation for server responses
- *
- * - **chatStore**: Uses ChatService for all AI model communication
- * - **conversationsStore**: Provides message context for API requests
- *
- * **Key Responsibilities:**
- * - Message format conversion (DatabaseMessage → API format)
- * - Streaming response handling with real-time callbacks
- * - Reasoning content extraction and processing
- * - File attachment processing (images, PDFs, audio, text)
- * - Request lifecycle management (abort via AbortSignal)
- */
-export class ChatService {
- // ─────────────────────────────────────────────────────────────────────────────
- // Messaging
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Sends a chat completion request to the llama.cpp server.
- * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
- * Automatically converts database messages with attachments to the appropriate API format.
- *
- * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments)
- * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details.
- * @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
- * @throws {Error} if the request fails or is aborted
- */
- static async sendMessage(
- messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
- options: SettingsChatServiceOptions = {},
- conversationId?: string,
- signal?: AbortSignal
- ): Promise<string | void> {
- const {
- stream,
- onChunk,
- onComplete,
- onError,
- onReasoningChunk,
- onToolCallChunk,
- onModel,
- onTimings,
- // Generation parameters
- temperature,
- max_tokens,
- // Sampling parameters
- dynatemp_range,
- dynatemp_exponent,
- top_k,
- top_p,
- min_p,
- xtc_probability,
- xtc_threshold,
- typ_p,
- // Penalty parameters
- repeat_last_n,
- repeat_penalty,
- presence_penalty,
- frequency_penalty,
- dry_multiplier,
- dry_base,
- dry_allowed_length,
- dry_penalty_last_n,
- // Other parameters
- samplers,
- backend_sampling,
- custom,
- timings_per_token,
- // Config options
- disableReasoningParsing
- } = options;
-
- const normalizedMessages: ApiChatMessageData[] = messages
- .map((msg) => {
- if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
- const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
- return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
- } else {
- return msg as ApiChatMessageData;
- }
- })
- .filter((msg) => {
- // Filter out empty system messages
- if (msg.role === 'system') {
- const content = typeof msg.content === 'string' ? msg.content : '';
-
- return content.trim().length > 0;
- }
-
- return true;
- });
-
- const requestBody: ApiChatCompletionRequest = {
- messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
- role: msg.role,
- content: msg.content
- })),
- stream,
- return_progress: stream ? true : undefined
- };
-
- // Include model in request if provided (required in ROUTER mode)
- if (options.model) {
- requestBody.model = options.model;
- }
-
- requestBody.reasoning_format = disableReasoningParsing ? 'none' : 'auto';
-
- if (temperature !== undefined) requestBody.temperature = temperature;
- if (max_tokens !== undefined) {
- // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
- requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
- }
-
- if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
- if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
- if (top_k !== undefined) requestBody.top_k = top_k;
- if (top_p !== undefined) requestBody.top_p = top_p;
- if (min_p !== undefined) requestBody.min_p = min_p;
- if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
- if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
- if (typ_p !== undefined) requestBody.typ_p = typ_p;
-
- if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
- if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
- if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
- if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
- if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
- if (dry_base !== undefined) requestBody.dry_base = dry_base;
- if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
- if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
-
- if (samplers !== undefined) {
- requestBody.samplers =
- typeof samplers === 'string'
- ? samplers.split(';').filter((s: string) => s.trim())
- : samplers;
- }
-
- if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
-
- if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
-
- if (custom) {
- try {
- const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
- Object.assign(requestBody, customParams);
- } catch (error) {
- console.warn('Failed to parse custom parameters:', error);
- }
- }
-
- try {
- const response = await fetch(`./v1/chat/completions`, {
- method: 'POST',
- headers: getJsonHeaders(),
- body: JSON.stringify(requestBody),
- signal
- });
-
- if (!response.ok) {
- const error = await ChatService.parseErrorResponse(response);
- if (onError) {
- onError(error);
- }
- throw error;
- }
-
- if (stream) {
- await ChatService.handleStreamResponse(
- response,
- onChunk,
- onComplete,
- onError,
- onReasoningChunk,
- onToolCallChunk,
- onModel,
- onTimings,
- conversationId,
- signal
- );
- return;
- } else {
- return ChatService.handleNonStreamResponse(
- response,
- onComplete,
- onError,
- onToolCallChunk,
- onModel
- );
- }
- } catch (error) {
- if (error instanceof Error && error.name === 'AbortError') {
- console.log('Chat completion request was aborted');
- return;
- }
-
- let userFriendlyError: Error;
-
- if (error instanceof Error) {
- if (error.name === 'TypeError' && error.message.includes('fetch')) {
- userFriendlyError = new Error(
- 'Unable to connect to server - please check if the server is running'
- );
- userFriendlyError.name = 'NetworkError';
- } else if (error.message.includes('ECONNREFUSED')) {
- userFriendlyError = new Error('Connection refused - server may be offline');
- userFriendlyError.name = 'NetworkError';
- } else if (error.message.includes('ETIMEDOUT')) {
- userFriendlyError = new Error('Request timed out - the server took too long to respond');
- userFriendlyError.name = 'TimeoutError';
- } else {
- userFriendlyError = error;
- }
- } else {
- userFriendlyError = new Error('Unknown error occurred while sending message');
- }
-
- console.error('Error in sendMessage:', error);
- if (onError) {
- onError(userFriendlyError);
- }
- throw userFriendlyError;
- }
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Streaming
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Handles streaming response from the chat completion API
- * @param response - The Response object from the fetch request
- * @param onChunk - Optional callback invoked for each content chunk received
- * @param onComplete - Optional callback invoked when the stream is complete with full response
- * @param onError - Optional callback invoked if an error occurs during streaming
- * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
- * @param conversationId - Optional conversation ID for per-conversation state tracking
- * @returns {Promise<void>} Promise that resolves when streaming is complete
- * @throws {Error} if the stream cannot be read or parsed
- */
- private static async handleStreamResponse(
- response: Response,
- onChunk?: (chunk: string) => void,
- onComplete?: (
- response: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings,
- toolCalls?: string
- ) => void,
- onError?: (error: Error) => void,
- onReasoningChunk?: (chunk: string) => void,
- onToolCallChunk?: (chunk: string) => void,
- onModel?: (model: string) => void,
- onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
- conversationId?: string,
- abortSignal?: AbortSignal
- ): Promise<void> {
- const reader = response.body?.getReader();
-
- if (!reader) {
- throw new Error('No response body');
- }
-
- const decoder = new TextDecoder();
- let aggregatedContent = '';
- let fullReasoningContent = '';
- let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
- let lastTimings: ChatMessageTimings | undefined;
- let streamFinished = false;
- let modelEmitted = false;
- let toolCallIndexOffset = 0;
- let hasOpenToolCallBatch = false;
-
- const finalizeOpenToolCallBatch = () => {
- if (!hasOpenToolCallBatch) {
- return;
- }
-
- toolCallIndexOffset = aggregatedToolCalls.length;
- hasOpenToolCallBatch = false;
- };
-
- const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
- if (!toolCalls || toolCalls.length === 0) {
- return;
- }
-
- aggregatedToolCalls = ChatService.mergeToolCallDeltas(
- aggregatedToolCalls,
- toolCalls,
- toolCallIndexOffset
- );
-
- if (aggregatedToolCalls.length === 0) {
- return;
- }
-
- hasOpenToolCallBatch = true;
-
- const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
-
- if (!serializedToolCalls) {
- return;
- }
-
- if (!abortSignal?.aborted) {
- onToolCallChunk?.(serializedToolCalls);
- }
- };
-
- try {
- let chunk = '';
- while (true) {
- if (abortSignal?.aborted) break;
-
- const { done, value } = await reader.read();
- if (done) break;
-
- if (abortSignal?.aborted) break;
-
- chunk += decoder.decode(value, { stream: true });
- const lines = chunk.split('\n');
- chunk = lines.pop() || '';
-
- for (const line of lines) {
- if (abortSignal?.aborted) break;
-
- if (line.startsWith('data: ')) {
- const data = line.slice(6);
- if (data === '[DONE]') {
- streamFinished = true;
- continue;
- }
-
- try {
- const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
- const content = parsed.choices[0]?.delta?.content;
- const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
- const toolCalls = parsed.choices[0]?.delta?.tool_calls;
- const timings = parsed.timings;
- const promptProgress = parsed.prompt_progress;
-
- const chunkModel = ChatService.extractModelName(parsed);
- if (chunkModel && !modelEmitted) {
- modelEmitted = true;
- onModel?.(chunkModel);
- }
-
- if (promptProgress) {
- ChatService.notifyTimings(undefined, promptProgress, onTimings);
- }
-
- if (timings) {
- ChatService.notifyTimings(timings, promptProgress, onTimings);
- lastTimings = timings;
- }
-
- if (content) {
- finalizeOpenToolCallBatch();
- aggregatedContent += content;
- if (!abortSignal?.aborted) {
- onChunk?.(content);
- }
- }
-
- if (reasoningContent) {
- finalizeOpenToolCallBatch();
- fullReasoningContent += reasoningContent;
- if (!abortSignal?.aborted) {
- onReasoningChunk?.(reasoningContent);
- }
- }
-
- processToolCallDelta(toolCalls);
- } catch (e) {
- console.error('Error parsing JSON chunk:', e);
- }
- }
- }
-
- if (abortSignal?.aborted) break;
- }
-
- if (abortSignal?.aborted) return;
-
- if (streamFinished) {
- finalizeOpenToolCallBatch();
-
- const finalToolCalls =
- aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
-
- onComplete?.(
- aggregatedContent,
- fullReasoningContent || undefined,
- lastTimings,
- finalToolCalls
- );
- }
- } catch (error) {
- const err = error instanceof Error ? error : new Error('Stream error');
-
- onError?.(err);
-
- throw err;
- } finally {
- reader.releaseLock();
- }
- }
-
- /**
- * Handles non-streaming response from the chat completion API.
- * Parses the JSON response and extracts the generated content.
- *
- * @param response - The fetch Response object containing the JSON data
- * @param onComplete - Optional callback invoked when response is successfully parsed
- * @param onError - Optional callback invoked if an error occurs during parsing
- * @returns {Promise<string>} Promise that resolves to the generated content string
- * @throws {Error} if the response cannot be parsed or is malformed
- */
- private static async handleNonStreamResponse(
- response: Response,
- onComplete?: (
- response: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings,
- toolCalls?: string
- ) => void,
- onError?: (error: Error) => void,
- onToolCallChunk?: (chunk: string) => void,
- onModel?: (model: string) => void
- ): Promise<string> {
- try {
- const responseText = await response.text();
-
- if (!responseText.trim()) {
- const noResponseError = new Error('No response received from server. Please try again.');
- throw noResponseError;
- }
-
- const data: ApiChatCompletionResponse = JSON.parse(responseText);
-
- const responseModel = ChatService.extractModelName(data);
- if (responseModel) {
- onModel?.(responseModel);
- }
-
- const content = data.choices[0]?.message?.content || '';
- const reasoningContent = data.choices[0]?.message?.reasoning_content;
- const toolCalls = data.choices[0]?.message?.tool_calls;
-
- if (reasoningContent) {
- console.log('Full reasoning content:', reasoningContent);
- }
-
- let serializedToolCalls: string | undefined;
-
- if (toolCalls && toolCalls.length > 0) {
- const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
-
- if (mergedToolCalls.length > 0) {
- serializedToolCalls = JSON.stringify(mergedToolCalls);
- if (serializedToolCalls) {
- onToolCallChunk?.(serializedToolCalls);
- }
- }
- }
-
- if (!content.trim() && !serializedToolCalls) {
- const noResponseError = new Error('No response received from server. Please try again.');
- throw noResponseError;
- }
-
- onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
-
- return content;
- } catch (error) {
- const err = error instanceof Error ? error : new Error('Parse error');
-
- onError?.(err);
-
- throw err;
- }
- }
-
- /**
- * Merges tool call deltas into an existing array of tool calls.
- * Handles both existing and new tool calls, updating existing ones and adding new ones.
- *
- * @param existing - The existing array of tool calls to merge into
- * @param deltas - The array of tool call deltas to merge
- * @param indexOffset - Optional offset to apply to the index of new tool calls
- * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
- */
- private static mergeToolCallDeltas(
- existing: ApiChatCompletionToolCall[],
- deltas: ApiChatCompletionToolCallDelta[],
- indexOffset = 0
- ): ApiChatCompletionToolCall[] {
- const result = existing.map((call) => ({
- ...call,
- function: call.function ? { ...call.function } : undefined
- }));
-
- for (const delta of deltas) {
- const index =
- typeof delta.index === 'number' && delta.index >= 0
- ? delta.index + indexOffset
- : result.length;
-
- while (result.length <= index) {
- result.push({ function: undefined });
- }
-
- const target = result[index]!;
-
- if (delta.id) {
- target.id = delta.id;
- }
-
- if (delta.type) {
- target.type = delta.type;
- }
-
- if (delta.function) {
- const fn = target.function ? { ...target.function } : {};
-
- if (delta.function.name) {
- fn.name = delta.function.name;
- }
-
- if (delta.function.arguments) {
- fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
- }
-
- target.function = fn;
- }
- }
-
- return result;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Conversion
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Converts a database message with attachments to API chat message format.
- * Processes various attachment types (images, text files, PDFs) and formats them
- * as content parts suitable for the chat completion API.
- *
- * @param message - Database message object with optional extra attachments
- * @param message.content - The text content of the message
- * @param message.role - The role of the message sender (user, assistant, system)
- * @param message.extra - Optional array of message attachments (images, files, etc.)
- * @returns {ApiChatMessageData} object formatted for the chat completion API
- * @static
- */
- static convertDbMessageToApiChatMessageData(
- message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
- ): ApiChatMessageData {
- if (!message.extra || message.extra.length === 0) {
- return {
- role: message.role as 'user' | 'assistant' | 'system',
- content: message.content
- };
- }
-
- const contentParts: ApiChatMessageContentPart[] = [];
-
- if (message.content) {
- contentParts.push({
- type: 'text',
- text: message.content
- });
- }
-
- const imageFiles = message.extra.filter(
- (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
- extra.type === AttachmentType.IMAGE
- );
-
- for (const image of imageFiles) {
- contentParts.push({
- type: 'image_url',
- image_url: { url: image.base64Url }
- });
- }
-
- const textFiles = message.extra.filter(
- (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
- extra.type === AttachmentType.TEXT
- );
-
- for (const textFile of textFiles) {
- contentParts.push({
- type: 'text',
- text: `\n\n--- File: ${textFile.name} ---\n${textFile.content}`
- });
- }
-
- // Handle legacy 'context' type from old webui (pasted content)
- const legacyContextFiles = message.extra.filter(
- (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
- extra.type === AttachmentType.LEGACY_CONTEXT
- );
-
- for (const legacyContextFile of legacyContextFiles) {
- contentParts.push({
- type: 'text',
- text: `\n\n--- File: ${legacyContextFile.name} ---\n${legacyContextFile.content}`
- });
- }
-
- const audioFiles = message.extra.filter(
- (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
- extra.type === AttachmentType.AUDIO
- );
-
- for (const audio of audioFiles) {
- contentParts.push({
- type: 'input_audio',
- input_audio: {
- data: audio.base64Data,
- format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
- }
- });
- }
-
- const pdfFiles = message.extra.filter(
- (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
- extra.type === AttachmentType.PDF
- );
-
- for (const pdfFile of pdfFiles) {
- if (pdfFile.processedAsImages && pdfFile.images) {
- for (let i = 0; i < pdfFile.images.length; i++) {
- contentParts.push({
- type: 'image_url',
- image_url: { url: pdfFile.images[i] }
- });
- }
- } else {
- contentParts.push({
- type: 'text',
- text: `\n\n--- PDF File: ${pdfFile.name} ---\n${pdfFile.content}`
- });
- }
- }
-
- return {
- role: message.role as 'user' | 'assistant' | 'system',
- content: contentParts
- };
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Utilities
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Parses error response and creates appropriate error with context information
- * @param response - HTTP response object
- * @returns Promise<Error> - Parsed error with context info if available
- */
- private static async parseErrorResponse(
- response: Response
- ): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
- try {
- const errorText = await response.text();
- const errorData: ApiErrorResponse = JSON.parse(errorText);
-
- const message = errorData.error?.message || 'Unknown server error';
- const error = new Error(message) as Error & {
- contextInfo?: { n_prompt_tokens: number; n_ctx: number };
- };
- error.name = response.status === 400 ? 'ServerError' : 'HttpError';
-
- if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
- error.contextInfo = {
- n_prompt_tokens: errorData.error.n_prompt_tokens,
- n_ctx: errorData.error.n_ctx
- };
- }
-
- return error;
- } catch {
- const fallback = new Error(
- `Server error (${response.status}): ${response.statusText}`
- ) as Error & {
- contextInfo?: { n_prompt_tokens: number; n_ctx: number };
- };
- fallback.name = 'HttpError';
- return fallback;
- }
- }
-
- /**
- * Extracts model name from Chat Completions API response data.
- * Handles various response formats including streaming chunks and final responses.
- *
- * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
- * in the response. We override it with the actual model name from serverStore.
- *
- * @param data - Raw response data from the Chat Completions API
- * @returns Model name string if found, undefined otherwise
- * @private
- */
- private static extractModelName(data: unknown): string | undefined {
- const asRecord = (value: unknown): Record<string, unknown> | undefined => {
- return typeof value === 'object' && value !== null
- ? (value as Record<string, unknown>)
- : undefined;
- };
-
- const getTrimmedString = (value: unknown): string | undefined => {
- return typeof value === 'string' && value.trim() ? value.trim() : undefined;
- };
-
- const root = asRecord(data);
- if (!root) return undefined;
-
- // 1) root (some implementations provide `model` at the top level)
- const rootModel = getTrimmedString(root.model);
- if (rootModel) return rootModel;
-
- // 2) streaming choice (delta) or final response (message)
- const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
- if (!firstChoice) return undefined;
-
- // priority: delta.model (first chunk) else message.model (final response)
- const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
- if (deltaModel) return deltaModel;
-
- const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
- if (messageModel) return messageModel;
-
- // avoid guessing from non-standard locations (metadata, etc.)
- return undefined;
- }
-
- /**
- * Calls the onTimings callback with timing data from streaming response.
- *
- * @param timings - Timing information from the Chat Completions API response
- * @param promptProgress - Prompt processing progress data
- * @param onTimingsCallback - Callback function to invoke with timing data
- * @private
- */
- private static notifyTimings(
- timings: ChatMessageTimings | undefined,
- promptProgress: ChatMessagePromptProgress | undefined,
- onTimingsCallback:
- | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
- | undefined
- ): void {
- if (!onTimingsCallback || (!timings && !promptProgress)) return;
-
- onTimingsCallback(timings, promptProgress);
- }
-}
-export { ChatService } from './chat';
+/**
+ *
+ * SERVICES
+ *
+ * Stateless service layer for API communication and data operations.
+ * Services handle protocol-level concerns (HTTP, WebSocket, MCP, IndexedDB)
+ * without managing reactive state — that responsibility belongs to stores.
+ *
+ * **Design Principles:**
+ * - All methods are static — no instance state
+ * - Pure I/O operations (network requests, database queries)
+ * - No Svelte runes or reactive primitives
+ * - Error handling at the protocol level; business-level error handling in stores
+ *
+ * **Architecture (bottom to top):**
+ * - **Services** (this layer): Stateless protocol communication
+ * - **Stores**: Reactive state management consuming services
+ * - **Components**: UI consuming stores
+ *
+ */
+
+/**
+ * **ChatService** - Chat Completions API communication layer
+ *
+ * Handles direct communication with the llama-server's `/v1/chat/completions` endpoint.
+ * Provides streaming and non-streaming response parsing, message format conversion
+ * (DatabaseMessage → API format), and request lifecycle management.
+ *
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API. Ephemeral and
+ * runtime-focused — sending messages, receiving streaming responses, managing request lifecycles.
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * Managed by conversationsStore, conversations persist across sessions.
+ *
+ * **Architecture & Relationships:**
+ * - **ChatService** (this class): Stateless API communication layer
+ * - Handles HTTP requests/responses with the llama-server
+ * - Manages streaming and non-streaming response parsing
+ * - Converts database messages to API format (multimodal, tool calls)
+ * - Handles error translation with user-friendly messages
+ *
+ * - **chatStore**: Primary consumer — uses ChatService for all AI model communication
+ * - **agenticStore**: Uses ChatService for multi-turn agentic loop streaming
+ * - **conversationsStore**: Provides message context for API requests
+ *
+ * **Key Responsibilities:**
+ * - Streaming response handling with real-time content/reasoning/tool-call callbacks
+ * - Non-streaming response parsing with complete response extraction
+ * - Database message to API format conversion (attachments, tool calls, multimodal)
+ * - Tool call delta merging for incremental streaming aggregation
+ * - Request parameter assembly (sampling, penalties, custom params)
+ * - File attachment processing (images, PDFs, audio, text, MCP prompts/resources)
+ * - Reasoning content stripping from prompt history to avoid KV cache pollution
+ * - Error translation (network, timeout, server errors → user-friendly messages)
+ *
+ * @see chatStore in stores/chat.svelte.ts — primary consumer for chat state management
+ * @see agenticStore in stores/agentic.svelte.ts — uses ChatService for agentic loop streaming
+ * @see conversationsStore in stores/conversations.svelte.ts — provides message context
+ */
+export { ChatService } from './chat.service';
+
+/**
+ * **DatabaseService** - IndexedDB persistence layer via Dexie ORM
+ *
+ * Provides stateless data access for conversations and messages using IndexedDB.
+ * Handles all low-level storage operations including branching tree structures,
+ * cascade deletions, and transaction safety for multi-table operations.
+ *
+ * **Architecture & Relationships (bottom to top):**
+ * - **DatabaseService** (this class): Stateless IndexedDB operations
+ * - Lowest layer — direct Dexie/IndexedDB communication
+ * - Pure CRUD operations without business logic
+ * - Handles branching tree structure (parent-child relationships)
+ * - Provides transaction safety for multi-table operations
+ *
+ * - **conversationsStore**: Reactive state management layer
+ * - Uses DatabaseService for all persistence operations
+ * - Manages conversation list, active conversation, and messages in memory
+ *
+ * - **chatStore**: Active AI interaction management
+ * - Uses conversationsStore for conversation context
+ * - Directly uses DatabaseService for message CRUD during streaming
+ *
+ * **Key Responsibilities:**
+ * - Conversation CRUD (create, read, update, delete)
+ * - Message CRUD with branching support (parent-child relationships)
+ * - Root message and system prompt creation
+ * - Cascade deletion of message branches (descendants)
+ * - Transaction-safe multi-table operations
+ * - Conversation import with duplicate detection
+ *
+ * **Database Schema:**
+ * - `conversations`: id, lastModified, currNode, name
+ * - `messages`: id, convId, type, role, timestamp, parent, children
+ *
+ * **Branching Model:**
+ * Messages form a tree structure where each message can have multiple children,
+ * enabling conversation branching and alternative response paths. The conversation's
+ * `currNode` tracks the currently active branch endpoint.
+ *
+ * @see conversationsStore in stores/conversations.svelte.ts — reactive layer on top of DatabaseService
+ * @see chatStore in stores/chat.svelte.ts — uses DatabaseService directly for message CRUD during streaming
+ */
export { DatabaseService } from './database.service';
+
+/**
+ * **ModelsService** - Model management API communication
+ *
+ * Handles communication with model-related endpoints for both MODEL (single model)
+ * and ROUTER (multi-model) server modes. Provides model listing, loading/unloading,
+ * and status checking without managing any model state.
+ *
+ * **Architecture & Relationships:**
+ * - **ModelsService** (this class): Stateless HTTP communication
+ * - Sends requests to model endpoints
+ * - Parses and returns typed API responses
+ * - Provides model status utility methods
+ *
+ * - **modelsStore**: Primary consumer — manages reactive model state
+ * - Calls ModelsService for all model API operations
+ * - Handles polling, caching, and state updates
+ *
+ * **Key Responsibilities:**
+ * - List available models via OpenAI-compatible `/v1/models` endpoint
+ * - Load/unload models via `/models/load` and `/models/unload` (ROUTER mode)
+ * - Model status queries (loaded, loading)
+ *
+ * **Server Mode Behavior:**
+ * - **MODEL mode**: Only `list()` is relevant — single model always loaded
+ * - **ROUTER mode**: Full lifecycle — `list()`, `listRouter()`, `load()`, `unload()`
+ *
+ * **Endpoints:**
+ * - `GET /v1/models` — OpenAI-compatible model list (both modes)
+ * - `POST /models/load` — Load a model (ROUTER mode only)
+ * - `POST /models/unload` — Unload a model (ROUTER mode only)
+ *
+ * @see modelsStore in stores/models.svelte.ts — primary consumer for reactive model state
+ */
export { ModelsService } from './models.service';
+
+/**
+ * **PropsService** - Server properties and capabilities retrieval
+ *
+ * Fetches server configuration, model information, and capabilities from the `/props`
+ * endpoint. Supports both global server props and per-model props (ROUTER mode).
+ *
+ * **Architecture & Relationships:**
+ * - **PropsService** (this class): Stateless HTTP communication
+ * - Fetches server properties from `/props` endpoint
+ * - Handles authentication and request parameters
+ * - Returns typed `ApiLlamaCppServerProps` responses
+ *
+ * - **serverStore**: Consumes global server properties (role detection, connection state)
+ * - **modelsStore**: Consumes per-model properties (modalities, context size)
+ * - **settingsStore**: Syncs default generation parameters from props response
+ *
+ * **Key Responsibilities:**
+ * - Fetch global server properties (default generation settings, modalities)
+ * - Fetch per-model properties in ROUTER mode via `?model=<id>` parameter
+ * - Handle autoload control to prevent unintended model loading
+ *
+ * **API Behavior:**
+ * - `GET /props` → Global server props (MODEL mode: includes modalities)
+ * - `GET /props?model=<id>` → Per-model props (ROUTER mode: model-specific modalities)
+ * - `&autoload=false` → Prevents model auto-loading when querying props
+ *
+ * @see serverStore in stores/server.svelte.ts — consumes global server props
+ * @see modelsStore in stores/models.svelte.ts — consumes per-model props for modalities
+ * @see settingsStore in stores/settings.svelte.ts — syncs default generation params from props
+ */
export { PropsService } from './props.service';
-export { ParameterSyncService, SYNCABLE_PARAMETERS } from './parameter-sync.service';
+
+/**
+ * **ParameterSyncService** - Server defaults and user settings synchronization
+ *
+ * Manages the complex logic of merging server-provided default parameters with
+ * user-configured overrides. Ensures the UI reflects the actual server state
+ * while preserving user customizations. Tracks parameter sources (server default
+ * vs user override) for display in the settings UI.
+ *
+ * **Architecture & Relationships:**
+ * - **ParameterSyncService** (this class): Stateless sync logic
+ * - Pure functions for parameter extraction, merging, and diffing
+ * - No side effects — receives data in, returns data out
+ * - Handles floating-point precision normalization
+ *
+ * - **settingsStore**: Primary consumer — calls sync methods during:
+ * - Initial load (`syncWithServerDefaults`)
+ * - Settings reset (`forceSyncWithServerDefaults`)
+ * - Parameter info queries (`getParameterInfo`)
+ *
+ * - **PropsService**: Provides raw server props that feed into extraction
+ *
+ * **Key Responsibilities:**
+ * - Extract syncable parameters from server `/props` response
+ * - Merge server defaults with user overrides (user wins)
+ * - Track parameter source (Custom vs Default) for UI badges
+ * - Validate server parameter values by type (number, string, boolean)
+ * - Create diffs between current settings and server defaults
+ * - Floating-point precision normalization for consistent comparisons
+ *
+ * **Parameter Source Priority:**
+ * 1. **User Override** (Custom badge) — explicitly set by user in settings
+ * 2. **Server Default** (Default badge) — from `/props` endpoint
+ * 3. **App Default** — hardcoded fallback when server props unavailable
+ *
+ * **Exports:**
+ * - `ParameterSyncService` class — static methods for sync logic
+ * - `SYNCABLE_PARAMETERS` — mapping of webui setting keys to server parameter keys
+ *
+ * @see settingsStore in stores/settings.svelte.ts — primary consumer for settings sync
+ * @see ChatSettingsParameterSourceIndicator — displays parameter source badges in UI
+ */
+export { ParameterSyncService } from './parameter-sync.service';
+/**
+ * chatStore - Reactive State Store for Chat Operations
+ *
+ * Manages chat lifecycle, streaming, message operations, and processing state.
+ *
+ * **Architecture & Relationships:**
+ * - **ChatService**: Stateless API layer (sendMessage, streaming)
+ * - **chatStore** (this): Reactive state + business logic
+ * - **conversationsStore**: Conversation persistence and navigation
+ *
+ * @see ChatService in services/chat.service.ts for API operations
+ */
+
+import { SvelteMap } from 'svelte/reactivity';
import { DatabaseService, ChatService } from '$lib/services';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
normalizeModelName,
filterByLeafNodeId,
findDescendantMessages,
- findLeafNode
+ findLeafNode,
+ isAbortError
} from '$lib/utils';
-import { SvelteMap } from 'svelte/reactivity';
-import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
+import { REASONING_TAGS } from '$lib/constants/agentic';
+import {
+ MAX_INACTIVE_CONVERSATION_STATES,
+ INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
+} from '$lib/constants/cache';
+import type {
+ ChatMessageTimings,
+ ChatMessagePromptProgress,
+ ChatStreamCallbacks,
+ ErrorDialogState
+} from '$lib/types/chat';
+import type { ApiProcessingState, DatabaseMessage, DatabaseMessageExtra } from '$lib/types';
+import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums';
+
+interface ConversationStateEntry {
+ lastAccessed: number;
+}
-/**
- * chatStore - Active AI interaction and streaming state management
- *
- * **Terminology - Chat vs Conversation:**
- * - **Chat**: The active interaction space with the Chat Completions API. Represents the
- * real-time streaming session, loading states, and UI visualization of AI communication.
- * A "chat" is ephemeral - it exists only while the user is actively interacting with the AI.
- * - **Conversation**: The persistent database entity storing all messages and metadata.
- * Managed by conversationsStore, conversations persist across sessions and page reloads.
- *
- * This store manages all active AI interactions including real-time streaming, response
- * generation, and per-chat loading states. It handles the runtime layer between UI and
- * AI backend, supporting concurrent streaming across multiple conversations.
- *
- * **Architecture & Relationships:**
- * - **chatStore** (this class): Active AI session and streaming management
- * - Manages real-time AI response streaming via ChatService
- * - Tracks per-chat loading and streaming states for concurrent sessions
- * - Handles message operations (send, edit, regenerate, branch)
- * - Coordinates with conversationsStore for persistence
- *
- * - **conversationsStore**: Provides conversation data and message arrays for chat context
- * - **ChatService**: Low-level API communication with llama.cpp server
- * - **DatabaseService**: Message persistence and retrieval
- *
- * **Key Features:**
- * - **AI Streaming**: Real-time token streaming with abort support
- * - **Concurrent Chats**: Independent loading/streaming states per conversation
- * - **Message Branching**: Edit, regenerate, and branch conversation trees
- * - **Error Handling**: Timeout and server error recovery with user feedback
- * - **Graceful Stop**: Save partial responses when stopping generation
- *
- * **State Management:**
- * - Global `isLoading` and `currentResponse` for active chat UI
- * - `chatLoadingStates` Map for per-conversation streaming tracking
- * - `chatStreamingStates` Map for per-conversation streaming content
- * - `processingStates` Map for per-conversation processing state (timing/context info)
- * - Automatic state sync when switching between conversations
- */
-class ChatStore {
- // ─────────────────────────────────────────────────────────────────────────────
- // State
- // ─────────────────────────────────────────────────────────────────────────────
+const countOccurrences = (source: string, token: string): number =>
+ source ? source.split(token).length - 1 : 0;
+const hasUnclosedReasoningTag = (content: string): boolean =>
+ countOccurrences(content, REASONING_TAGS.START) > countOccurrences(content, REASONING_TAGS.END);
+const wrapReasoningContent = (content: string, reasoningContent?: string): string => {
+ if (!reasoningContent) return content;
+ return `${REASONING_TAGS.START}${reasoningContent}${REASONING_TAGS.END}${content}`;
+};
+class ChatStore {
activeProcessingState = $state<ApiProcessingState | null>(null);
currentResponse = $state('');
- errorDialogState = $state<{
- type: 'timeout' | 'server';
- message: string;
- contextInfo?: { n_prompt_tokens: number; n_ctx: number };
- } | null>(null);
+ errorDialogState = $state<ErrorDialogState | null>(null);
isLoading = $state(false);
chatLoadingStates = new SvelteMap<string, boolean>();
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
private abortControllers = new SvelteMap<string, AbortController>();
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
+ private conversationStateTimestamps = new SvelteMap<string, ConversationStateEntry>();
private activeConversationId = $state<string | null>(null);
private isStreamingActive = $state(false);
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
- // Draft preservation for navigation (e.g., when adding system prompt from welcome page)
+ private messageUpdateCallback:
+ | ((messageId: string, updates: Partial<DatabaseMessage>) => void)
+ | null = null;
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
- // ─────────────────────────────────────────────────────────────────────────────
- // Loading State
- // ─────────────────────────────────────────────────────────────────────────────
-
private setChatLoading(convId: string, loading: boolean): void {
+ this.touchConversationState(convId);
if (loading) {
this.chatLoadingStates.set(convId, true);
- if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
+ if (convId === conversationsStore.activeConversation?.id) this.isLoading = true;
} else {
this.chatLoadingStates.delete(convId);
- if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
+ if (convId === conversationsStore.activeConversation?.id) this.isLoading = false;
}
}
-
- private isChatLoading(convId: string): boolean {
- return this.chatLoadingStates.get(convId) || false;
- }
-
private setChatStreaming(convId: string, response: string, messageId: string): void {
+ this.touchConversationState(convId);
this.chatStreamingStates.set(convId, { response, messageId });
- if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
+ if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
}
-
private clearChatStreaming(convId: string): void {
this.chatStreamingStates.delete(convId);
- if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
+ if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
}
-
private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
return this.chatStreamingStates.get(convId);
}
-
syncLoadingStateForChat(convId: string): void {
- this.isLoading = this.isChatLoading(convId);
- const streamingState = this.getChatStreaming(convId);
- this.currentResponse = streamingState?.response || '';
- this.isStreamingActive = streamingState !== undefined;
+ this.isLoading = this.chatLoadingStates.get(convId) || false;
+ const s = this.chatStreamingStates.get(convId);
+ this.currentResponse = s?.response || '';
+ this.isStreamingActive = s !== undefined;
this.setActiveProcessingConversation(convId);
-
// Sync streaming content to activeMessages so UI displays current content
- if (streamingState?.response && streamingState?.messageId) {
- const idx = conversationsStore.findMessageIndex(streamingState.messageId);
+ if (s?.response && s?.messageId) {
+ const idx = conversationsStore.findMessageIndex(s.messageId);
if (idx !== -1) {
- conversationsStore.updateMessageAtIndex(idx, { content: streamingState.response });
+ conversationsStore.updateMessageAtIndex(idx, { content: s.response });
}
}
}
- /**
- * Clears global UI state without affecting background streaming.
- * Used when navigating to empty/new chat while other chats stream in background.
- */
clearUIState(): void {
this.isLoading = false;
this.currentResponse = '';
this.isStreamingActive = false;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Processing State
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Set the active conversation for statistics display
- */
setActiveProcessingConversation(conversationId: string | null): void {
this.activeConversationId = conversationId;
-
- if (conversationId) {
- this.activeProcessingState = this.processingStates.get(conversationId) || null;
- } else {
- this.activeProcessingState = null;
- }
+ this.activeProcessingState = conversationId
+ ? this.processingStates.get(conversationId) || null
+ : null;
}
- /**
- * Get processing state for a specific conversation
- */
getProcessingState(conversationId: string): ApiProcessingState | null {
return this.processingStates.get(conversationId) || null;
}
- /**
- * Clear processing state for a specific conversation
- */
+ private setProcessingState(conversationId: string, state: ApiProcessingState | null): void {
+ if (state === null) this.processingStates.delete(conversationId);
+ else this.processingStates.set(conversationId, state);
+ if (conversationId === this.activeConversationId) this.activeProcessingState = state;
+ }
+
clearProcessingState(conversationId: string): void {
this.processingStates.delete(conversationId);
-
- if (conversationId === this.activeConversationId) {
- this.activeProcessingState = null;
- }
+ if (conversationId === this.activeConversationId) this.activeProcessingState = null;
}
- /**
- * Get the current processing state for the active conversation (reactive)
- * Returns the direct reactive state for UI binding
- */
getActiveProcessingState(): ApiProcessingState | null {
return this.activeProcessingState;
}
- /**
- * Updates processing state with timing data from streaming response
- */
- updateProcessingStateFromTimings(
- timingData: {
- prompt_n: number;
- prompt_ms?: number;
- predicted_n: number;
- predicted_per_second: number;
- cache_n: number;
- prompt_progress?: ChatMessagePromptProgress;
- },
- conversationId?: string
- ): void {
- const processingState = this.parseTimingData(timingData);
-
- if (processingState === null) {
- console.warn('Failed to parse timing data - skipping update');
- return;
- }
-
- const targetId = conversationId || this.activeConversationId;
- if (targetId) {
- this.processingStates.set(targetId, processingState);
-
- if (targetId === this.activeConversationId) {
- this.activeProcessingState = processingState;
- }
- }
- }
-
- /**
- * Get current processing state (sync version for reactive access)
- */
getCurrentProcessingStateSync(): ApiProcessingState | null {
return this.activeProcessingState;
}
- /**
- * Restore processing state from last assistant message timings
- * Call this when keepStatsVisible is enabled and we need to show last known stats
- */
- restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
- if (message.role === 'assistant' && message.timings) {
- const restoredState = this.parseTimingData({
- prompt_n: message.timings.prompt_n || 0,
- prompt_ms: message.timings.prompt_ms,
- predicted_n: message.timings.predicted_n || 0,
- predicted_per_second:
- message.timings.predicted_n && message.timings.predicted_ms
- ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
- : 0,
- cache_n: message.timings.cache_n || 0
- });
+ private setStreamingActive(active: boolean): void {
+ this.isStreamingActive = active;
+ }
- if (restoredState) {
- this.processingStates.set(conversationId, restoredState);
+ isStreaming(): boolean {
+ return this.isStreamingActive;
+ }
- if (conversationId === this.activeConversationId) {
- this.activeProcessingState = restoredState;
- }
+ private getOrCreateAbortController(convId: string): AbortController {
+ let c = this.abortControllers.get(convId);
+ if (!c || c.signal.aborted) {
+ c = new AbortController();
+ this.abortControllers.set(convId, c);
+ }
+ return c;
+ }
- return;
- }
+ private abortRequest(convId?: string): void {
+ if (convId) {
+ const c = this.abortControllers.get(convId);
+ if (c) {
+ c.abort();
+ this.abortControllers.delete(convId);
}
+ } else {
+ for (const c of this.abortControllers.values()) c.abort();
+ this.abortControllers.clear();
}
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Streaming
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Start streaming session tracking
- */
- startStreaming(): void {
- this.isStreamingActive = true;
+ private showErrorDialog(state: ErrorDialogState | null): void {
+ this.errorDialogState = state;
}
- /**
- * Stop streaming session tracking
- */
- stopStreaming(): void {
- this.isStreamingActive = false;
+ dismissErrorDialog(): void {
+ this.errorDialogState = null;
}
- /**
- * Check if currently in a streaming session
- */
- isStreaming(): boolean {
- return this.isStreamingActive;
+ clearEditMode(): void {
+ this.isEditModeActive = false;
+ this.addFilesHandler = null;
}
- private getContextTotal(): number {
- const activeState = this.getActiveProcessingState();
-
- if (activeState && activeState.contextTotal > 0) {
- return activeState.contextTotal;
- }
-
- if (isRouterMode()) {
- const modelContextSize = selectedModelContextSize();
- if (modelContextSize && modelContextSize > 0) {
- return modelContextSize;
- }
- }
+ isEditing(): boolean {
+ return this.isEditModeActive;
+ }
- const propsContextSize = contextSize();
- if (propsContextSize && propsContextSize > 0) {
- return propsContextSize;
- }
+ setEditModeActive(handler: (files: File[]) => void): void {
+ this.isEditModeActive = true;
+ this.addFilesHandler = handler;
+ }
- return DEFAULT_CONTEXT;
+ getAddFilesHandler(): ((files: File[]) => void) | null {
+ return this.addFilesHandler;
}
- private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
- const promptTokens = (timingData.prompt_n as number) || 0;
- const promptMs = (timingData.prompt_ms as number) || undefined;
- const predictedTokens = (timingData.predicted_n as number) || 0;
- const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
- const cacheTokens = (timingData.cache_n as number) || 0;
- const promptProgress = timingData.prompt_progress as
- | {
- total: number;
- cache: number;
- processed: number;
- time_ms: number;
- }
- | undefined;
+ clearPendingEditMessageId(): void {
+ this.pendingEditMessageId = null;
+ }
- const contextTotal = this.getContextTotal();
- const currentConfig = config();
- const outputTokensMax = currentConfig.max_tokens || -1;
+ savePendingDraft(message: string, files: ChatUploadedFile[]): void {
+ this._pendingDraftMessage = message;
+ this._pendingDraftFiles = [...files];
+ }
- // Note: for timings data, the n_prompt does NOT include cache tokens
- const contextUsed = promptTokens + cacheTokens + predictedTokens;
- const outputTokensUsed = predictedTokens;
+ consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
+ if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null;
+ const d = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] };
+ this._pendingDraftMessage = '';
+ this._pendingDraftFiles = [];
+ return d;
+ }
- // Note: for prompt progress, the "processed" DOES include cache tokens
- // we need to exclude them to get the real prompt tokens processed count
- const progressCache = promptProgress?.cache || 0;
- const progressActualDone = (promptProgress?.processed ?? 0) - progressCache;
- const progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
- const progressPercent = promptProgress
- ? Math.round((progressActualDone / progressActualTotal) * 100)
- : undefined;
+ hasPendingDraft(): boolean {
+ return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0;
+ }
- return {
- status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
- tokensDecoded: predictedTokens,
- tokensRemaining: outputTokensMax - predictedTokens,
- contextUsed,
- contextTotal,
- outputTokensUsed,
- outputTokensMax,
- hasNextToken: predictedTokens > 0,
- tokensPerSecond,
- temperature: currentConfig.temperature ?? 0.8,
- topP: currentConfig.top_p ?? 0.95,
- speculative: false,
- progressPercent,
- promptProgress,
- promptTokens,
- promptMs,
- cacheTokens
- };
+ getAllLoadingChats(): string[] {
+ return Array.from(this.chatLoadingStates.keys());
}
- /**
- * Gets the model used in a conversation based on the latest assistant message.
- * Returns the model from the most recent assistant message that has a model field set.
- *
- * @param messages - Array of messages to search through
- * @returns The model name or null if no model found
- */
- getConversationModel(messages: DatabaseMessage[]): string | null {
- // Search backwards through messages to find most recent assistant message with model
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
- if (message.role === 'assistant' && message.model) {
- return message.model;
- }
- }
- return null;
+ getAllStreamingChats(): string[] {
+ return Array.from(this.chatStreamingStates.keys());
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Error Handling
- // ─────────────────────────────────────────────────────────────────────────────
+ getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined {
+ return this.getChatStreaming(convId);
+ }
- private isAbortError(error: unknown): boolean {
- return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
+ isChatLoadingPublic(convId: string): boolean {
+ return this.chatLoadingStates.get(convId) || false;
}
- private showErrorDialog(
- type: 'timeout' | 'server',
- message: string,
- contextInfo?: { n_prompt_tokens: number; n_ctx: number }
- ): void {
- this.errorDialogState = { type, message, contextInfo };
+ private isChatLoadingInternal(convId: string): boolean {
+ return this.chatStreamingStates.has(convId);
}
- dismissErrorDialog(): void {
- this.errorDialogState = null;
+ private touchConversationState(convId: string): void {
+ this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() });
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Message Operations
- // ─────────────────────────────────────────────────────────────────────────────
+ cleanupOldConversationStates(activeConversationIds?: string[]): number {
+ const now = Date.now();
+ const activeIdsList = activeConversationIds ?? [];
+ const preserveIds = this.activeConversationId
+ ? [...activeIdsList, this.activeConversationId]
+ : activeIdsList;
+ const allConvIds = [
+ ...new Set([
+ ...this.chatLoadingStates.keys(),
+ ...this.chatStreamingStates.keys(),
+ ...this.abortControllers.keys(),
+ ...this.processingStates.keys(),
+ ...this.conversationStateTimestamps.keys()
+ ])
+ ];
+ const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = [];
+ for (const convId of allConvIds) {
+ if (preserveIds.includes(convId)) continue;
+ if (this.chatLoadingStates.get(convId)) continue;
+ if (this.chatStreamingStates.has(convId)) continue;
+ const ts = this.conversationStateTimestamps.get(convId);
+ cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 });
+ }
+ cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
+ let cleanedUp = 0;
+ for (const { convId, lastAccessed } of cleanupCandidates) {
+ if (
+ cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES ||
+ now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
+ ) {
+ this.cleanupConversationState(convId);
+ cleanedUp++;
+ }
+ }
+ return cleanedUp;
+ }
+ private cleanupConversationState(convId: string): void {
+ const c = this.abortControllers.get(convId);
+ if (c && !c.signal.aborted) c.abort();
+ this.chatLoadingStates.delete(convId);
+ this.chatStreamingStates.delete(convId);
+ this.abortControllers.delete(convId);
+ this.processingStates.delete(convId);
+ this.conversationStateTimestamps.delete(convId);
+ }
+ getTrackedConversationCount(): number {
+ return new Set([
+ ...this.chatLoadingStates.keys(),
+ ...this.chatStreamingStates.keys(),
+ ...this.abortControllers.keys(),
+ ...this.processingStates.keys()
+ ]).size;
+ }
- /**
- * Finds a message by ID and optionally validates its role.
- * Returns message and index, or null if not found or role doesn't match.
- */
private getMessageByIdWithRole(
messageId: string,
- expectedRole?: ChatRole
+ expectedRole?: MessageRole
): { message: DatabaseMessage; index: number } | null {
const index = conversationsStore.findMessageIndex(messageId);
if (index === -1) return null;
-
const message = conversationsStore.activeMessages[index];
if (expectedRole && message.role !== expectedRole) return null;
-
return { message, index };
}
async addMessage(
- role: ChatRole,
+ role: MessageRole,
content: string,
- type: ChatMessageType = 'text',
+ type: MessageType = MessageType.TEXT,
parent: string = '-1',
extras?: DatabaseMessageExtra[]
- ): Promise<DatabaseMessage | null> {
+ ): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv) {
- console.error('No active conversation when trying to add message');
- return null;
- }
-
- try {
- let parentId: string | null = null;
-
- if (parent === '-1') {
- const activeMessages = conversationsStore.activeMessages;
- if (activeMessages.length > 0) {
- parentId = activeMessages[activeMessages.length - 1].id;
- } else {
- const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
- const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
- if (!rootMessage) {
- parentId = await DatabaseService.createRootMessage(activeConv.id);
- } else {
- parentId = rootMessage.id;
- }
- }
- } else {
- parentId = parent;
+ if (!activeConv) throw new Error('No active conversation');
+ let parentId: string | null = null;
+ if (parent === '-1') {
+ const am = conversationsStore.activeMessages;
+ if (am.length > 0) parentId = am[am.length - 1].id;
+ else {
+ const all = await conversationsStore.getConversationMessages(activeConv.id);
+ const r = all.find((m) => m.parent === null && m.type === 'root');
+ parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id);
}
-
- const message = await DatabaseService.createMessageBranch(
- {
- convId: activeConv.id,
- role,
- content,
- type,
- timestamp: Date.now(),
- thinking: '',
- toolCalls: '',
- children: [],
- extra: extras
- },
- parentId
- );
-
- conversationsStore.addMessageToActive(message);
- await conversationsStore.updateCurrentNode(message.id);
- conversationsStore.updateConversationTimestamp();
-
- return message;
- } catch (error) {
- console.error('Failed to add message:', error);
- return null;
- }
+ } else parentId = parent;
+ const message = await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ role,
+ content,
+ type,
+ timestamp: Date.now(),
+ toolCalls: '',
+ children: [],
+ extra: extras
+ },
+ parentId
+ );
+ conversationsStore.addMessageToActive(message);
+ await conversationsStore.updateCurrentNode(message.id);
+ conversationsStore.updateConversationTimestamp();
+ return message;
}
- /**
- * Adds a system message at the top of a conversation and triggers edit mode.
- * The system message is inserted between root and the first message of the active branch.
- * Creates a new conversation if one doesn't exist.
- */
async addSystemPrompt(): Promise<void> {
let activeConv = conversationsStore.activeConversation;
-
- // Create conversation if needed
if (!activeConv) {
await conversationsStore.createConversation();
activeConv = conversationsStore.activeConversation;
}
if (!activeConv) return;
-
try {
- // Get all messages to find the root
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
- let rootId: string;
-
- // Create root message if it doesn't exist
- if (!rootMessage) {
- rootId = await DatabaseService.createRootMessage(activeConv.id);
- } else {
- rootId = rootMessage.id;
- }
-
- // Check if there's already a system message as root's child
+ const rootId = rootMessage
+ ? rootMessage.id
+ : await DatabaseService.createRootMessage(activeConv.id);
const existingSystemMessage = allMessages.find(
- (m) => m.role === 'system' && m.parent === rootId
+ (m) => m.role === MessageRole.SYSTEM && m.parent === rootId
);
-
if (existingSystemMessage) {
- // If system message exists, just trigger edit mode on it
this.pendingEditMessageId = existingSystemMessage.id;
-
- // Make sure it's in active messages at the beginning
- if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) {
+ if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id))
conversationsStore.activeMessages.unshift(existingSystemMessage);
- }
return;
}
-
- // Find the first message of the active branch (child of root that's in activeMessages)
- const activeMessages = conversationsStore.activeMessages;
- const firstActiveMessage = activeMessages.find((m) => m.parent === rootId);
-
- // Create new system message with placeholder content (will be edited by user)
+ const am = conversationsStore.activeMessages;
+ const firstActiveMessage = am.find((m) => m.parent === rootId);
const systemMessage = await DatabaseService.createSystemMessage(
activeConv.id,
SYSTEM_MESSAGE_PLACEHOLDER,
rootId
);
-
- // If there's a first message in the active branch, re-parent it to the system message
if (firstActiveMessage) {
- // Update the first message's parent to be the system message
- await DatabaseService.updateMessage(firstActiveMessage.id, {
- parent: systemMessage.id
- });
-
- // Update the system message's children to include the first message
+ await DatabaseService.updateMessage(firstActiveMessage.id, { parent: systemMessage.id });
await DatabaseService.updateMessage(systemMessage.id, {
children: [firstActiveMessage.id]
});
-
- // Remove first message from root's children
const updatedRootChildren = rootMessage
? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id)
: [];
- // Note: system message was already added to root's children by createSystemMessage
await DatabaseService.updateMessage(rootId, {
children: [
...updatedRootChildren.filter((id: string) => id !== systemMessage.id),
systemMessage.id
]
});
-
- // Update local state
const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id);
- if (firstMsgIndex !== -1) {
+ if (firstMsgIndex !== -1)
conversationsStore.updateMessageAtIndex(firstMsgIndex, { parent: systemMessage.id });
- }
}
-
- // Add system message to active messages at the beginning
conversationsStore.activeMessages.unshift(systemMessage);
-
- // Set pending edit message ID to trigger edit mode
this.pendingEditMessageId = systemMessage.id;
-
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to add system prompt:', error);
}
}
- /**
- * Removes a system message placeholder without deleting its children.
- * Re-parents children back to the root message.
- * If this is a new empty conversation (only root + system placeholder), deletes the entire conversation.
- * @returns true if the entire conversation was deleted, false otherwise
- */
async removeSystemPromptPlaceholder(messageId: string): Promise<boolean> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return false;
-
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const systemMessage = allMessages.find((m) => m.id === messageId);
- if (!systemMessage || systemMessage.role !== 'system') return false;
-
+ if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false;
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
if (!rootMessage) return false;
-
- // Check if this is a new empty conversation (only root + system placeholder)
- const isEmptyConversation = allMessages.length === 2 && systemMessage.children.length === 0;
-
- if (isEmptyConversation) {
- // Delete the entire conversation
+ if (allMessages.length === 2 && systemMessage.children.length === 0) {
await conversationsStore.deleteConversation(activeConv.id);
return true;
}
-
- // Re-parent system message's children to root
for (const childId of systemMessage.children) {
await DatabaseService.updateMessage(childId, { parent: rootMessage.id });
-
- // Update local state
const childIndex = conversationsStore.findMessageIndex(childId);
- if (childIndex !== -1) {
+ if (childIndex !== -1)
conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id });
- }
}
-
- // Update root's children: remove system message, add system's children
- const newRootChildren = [
- ...rootMessage.children.filter((id: string) => id !== messageId),
- ...systemMessage.children
- ];
- await DatabaseService.updateMessage(rootMessage.id, { children: newRootChildren });
-
- // Delete the system message (without cascade)
+ await DatabaseService.updateMessage(rootMessage.id, {
+ children: [
+ ...rootMessage.children.filter((id: string) => id !== messageId),
+ ...systemMessage.children
+ ]
+ });
await DatabaseService.deleteMessage(messageId);
-
- // Remove from active messages
const systemIndex = conversationsStore.findMessageIndex(messageId);
- if (systemIndex !== -1) {
- conversationsStore.activeMessages.splice(systemIndex, 1);
- }
-
+ if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1);
conversationsStore.updateConversationTimestamp();
return false;
} catch (error) {
}
}
- private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
+ private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv) return null;
-
+ if (!activeConv) throw new Error('No active conversation');
return await DatabaseService.createMessageBranch(
{
convId: activeConv.id,
- type: 'text',
- role: 'assistant',
+ type: MessageType.TEXT,
+ role: MessageRole.ASSISTANT,
content: '',
timestamp: Date.now(),
- thinking: '',
toolCalls: '',
children: [],
model: null
);
}
- private async streamChatCompletion(
- allMessages: DatabaseMessage[],
- assistantMessage: DatabaseMessage,
- onComplete?: (content: string) => Promise<void>,
- onError?: (error: Error) => void,
- modelOverride?: string | null
- ): Promise<void> {
- // Ensure model props are cached before streaming (for correct n_ctx in processing info)
- if (isRouterMode()) {
- const modelName = modelOverride || selectedModelName();
- if (modelName && !modelsStore.getModelProps(modelName)) {
- await modelsStore.fetchModelProps(modelName);
- }
- }
-
- let streamedContent = '';
- let streamedReasoningContent = '';
- let streamedToolCallContent = '';
- let resolvedModel: string | null = null;
- let modelPersisted = false;
-
- const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
- if (!modelName) return;
- const normalizedModel = normalizeModelName(modelName);
- if (!normalizedModel || normalizedModel === resolvedModel) return;
- resolvedModel = normalizedModel;
- const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id);
- conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel });
- if (persistImmediately && !modelPersisted) {
- modelPersisted = true;
- DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => {
- modelPersisted = false;
- resolvedModel = null;
- });
- }
- };
-
- this.startStreaming();
- this.setActiveProcessingConversation(assistantMessage.convId);
-
- const abortController = this.getOrCreateAbortController(assistantMessage.convId);
-
- await ChatService.sendMessage(
- allMessages,
- {
- ...this.getApiOptions(),
- ...(modelOverride ? { model: modelOverride } : {}),
- onChunk: (chunk: string) => {
- streamedContent += chunk;
- this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
- conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
- },
- onReasoningChunk: (reasoningChunk: string) => {
- streamedReasoningContent += reasoningChunk;
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
- conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent });
- },
- onToolCallChunk: (toolCallChunk: string) => {
- const chunk = toolCallChunk.trim();
- if (!chunk) return;
- streamedToolCallContent = chunk;
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
- conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
- },
- onModel: (modelName: string) => recordModel(modelName),
- onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
- const tokensPerSecond =
- timings?.predicted_ms && timings?.predicted_n
- ? (timings.predicted_n / timings.predicted_ms) * 1000
- : 0;
- this.updateProcessingStateFromTimings(
- {
- prompt_n: timings?.prompt_n || 0,
- prompt_ms: timings?.prompt_ms,
- predicted_n: timings?.predicted_n || 0,
- predicted_per_second: tokensPerSecond,
- cache_n: timings?.cache_n || 0,
- prompt_progress: promptProgress
- },
- assistantMessage.convId
- );
- },
- onComplete: async (
- finalContent?: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings,
- toolCallContent?: string
- ) => {
- this.stopStreaming();
-
- const updateData: Record<string, unknown> = {
- content: finalContent || streamedContent,
- thinking: reasoningContent || streamedReasoningContent,
- toolCalls: toolCallContent || streamedToolCallContent,
- timings
- };
- if (resolvedModel && !modelPersisted) {
- updateData.model = resolvedModel;
- }
- await DatabaseService.updateMessage(assistantMessage.id, updateData);
-
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
- const uiUpdate: Partial<DatabaseMessage> = {
- content: updateData.content as string,
- toolCalls: updateData.toolCalls as string
- };
- if (timings) uiUpdate.timings = timings;
- if (resolvedModel) uiUpdate.model = resolvedModel;
-
- conversationsStore.updateMessageAtIndex(idx, uiUpdate);
- await conversationsStore.updateCurrentNode(assistantMessage.id);
-
- if (onComplete) await onComplete(streamedContent);
- this.setChatLoading(assistantMessage.convId, false);
- this.clearChatStreaming(assistantMessage.convId);
- this.clearProcessingState(assistantMessage.convId);
-
- if (isRouterMode()) {
- modelsStore.fetchRouterModels().catch(console.error);
- }
- },
- onError: (error: Error) => {
- this.stopStreaming();
-
- if (this.isAbortError(error)) {
- this.setChatLoading(assistantMessage.convId, false);
- this.clearChatStreaming(assistantMessage.convId);
- this.clearProcessingState(assistantMessage.convId);
-
- return;
- }
-
- console.error('Streaming error:', error);
-
- this.setChatLoading(assistantMessage.convId, false);
- this.clearChatStreaming(assistantMessage.convId);
- this.clearProcessingState(assistantMessage.convId);
-
- const idx = conversationsStore.findMessageIndex(assistantMessage.id);
-
- if (idx !== -1) {
- const failedMessage = conversationsStore.removeMessageAtIndex(idx);
- if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
- }
-
- const contextInfo = (
- error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
- ).contextInfo;
-
- this.showErrorDialog(
- error.name === 'TimeoutError' ? 'timeout' : 'server',
- error.message,
- contextInfo
- );
-
- if (onError) onError(error);
- }
- },
- assistantMessage.convId,
- abortController.signal
- );
- }
-
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if (!content.trim() && (!extras || extras.length === 0)) return;
const activeConv = conversationsStore.activeConversation;
- if (activeConv && this.isChatLoading(activeConv.id)) return;
+ if (activeConv && this.isChatLoadingInternal(activeConv.id)) return;
let isNewConversation = false;
if (!activeConv) {
}
const currentConv = conversationsStore.activeConversation;
if (!currentConv) return;
-
- this.errorDialogState = null;
+ this.showErrorDialog(null);
this.setChatLoading(currentConv.id, true);
this.clearChatStreaming(currentConv.id);
-
try {
+ let parentIdForUserMessage: string | undefined;
if (isNewConversation) {
const rootId = await DatabaseService.createRootMessage(currentConv.id);
const currentConfig = config();
const systemPrompt = currentConfig.systemMessage?.toString().trim();
-
if (systemPrompt) {
const systemMessage = await DatabaseService.createSystemMessage(
currentConv.id,
systemPrompt,
rootId
);
-
conversationsStore.addMessageToActive(systemMessage);
- }
+ parentIdForUserMessage = systemMessage.id;
+ } else parentIdForUserMessage = rootId;
}
-
- const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
- if (!userMessage) throw new Error('Failed to add user message');
+ const userMessage = await this.addMessage(
+ MessageRole.USER,
+ content,
+ MessageType.TEXT,
+ parentIdForUserMessage ?? '-1'
+ );
if (isNewConversation && content)
await conversationsStore.updateConversationName(currentConv.id, content.trim());
-
const assistantMessage = await this.createAssistantMessage(userMessage.id);
-
- if (!assistantMessage) throw new Error('Failed to create assistant message');
-
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage
);
} catch (error) {
- if (this.isAbortError(error)) {
+ if (isAbortError(error)) {
this.setChatLoading(currentConv.id, false);
return;
}
console.error('Failed to send message:', error);
this.setChatLoading(currentConv.id, false);
- if (!this.errorDialogState) {
- const dialogType =
- error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server';
+ const dialogType =
+ error instanceof Error && error.name === 'TimeoutError'
+ ? ErrorDialogType.TIMEOUT
+ : ErrorDialogType.SERVER;
+ const contextInfo = (
+ error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
+ ).contextInfo;
+ this.showErrorDialog({
+ type: dialogType,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ contextInfo
+ });
+ }
+ }
+
+ private async streamChatCompletion(
+ allMessages: DatabaseMessage[],
+ assistantMessage: DatabaseMessage,
+ onComplete?: (content: string) => Promise<void>,
+ onError?: (error: Error) => void,
+ modelOverride?: string | null
+ ): Promise<void> {
+ let effectiveModel = modelOverride;
+
+ if (isRouterMode() && !effectiveModel) {
+ const conversationModel = this.getConversationModel(allMessages);
+ effectiveModel = selectedModelName() || conversationModel;
+ }
+
+ if (isRouterMode() && effectiveModel) {
+ if (!modelsStore.getModelProps(effectiveModel))
+ await modelsStore.fetchModelProps(effectiveModel);
+ }
+
+ let streamedContent = '',
+ streamedToolCallContent = '',
+ isReasoningOpen = false,
+ hasStreamedChunks = false,
+ resolvedModel: string | null = null,
+ modelPersisted = false;
+ let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra
+ ? JSON.parse(JSON.stringify(assistantMessage.extra))
+ : [];
+ const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
+ if (!modelName) return;
+ const n = normalizeModelName(modelName);
+ if (!n || n === resolvedModel) return;
+ resolvedModel = n;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { model: n });
+ if (persistImmediately && !modelPersisted) {
+ modelPersisted = true;
+ DatabaseService.updateMessage(assistantMessage.id, { model: n }).catch(() => {
+ modelPersisted = false;
+ resolvedModel = null;
+ });
+ }
+ };
+ const updateStreamingContent = () => {
+ this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
+ };
+ const appendContentChunk = (chunk: string) => {
+ if (isReasoningOpen) {
+ streamedContent += REASONING_TAGS.END;
+ isReasoningOpen = false;
+ }
+ streamedContent += chunk;
+ hasStreamedChunks = true;
+ updateStreamingContent();
+ };
+ const appendReasoningChunk = (chunk: string) => {
+ if (!isReasoningOpen) {
+ streamedContent += REASONING_TAGS.START;
+ isReasoningOpen = true;
+ }
+ streamedContent += chunk;
+ hasStreamedChunks = true;
+ updateStreamingContent();
+ };
+ const finalizeReasoning = () => {
+ if (isReasoningOpen) {
+ streamedContent += REASONING_TAGS.END;
+ isReasoningOpen = false;
+ }
+ };
+ this.setStreamingActive(true);
+ this.setActiveProcessingConversation(assistantMessage.convId);
+ const abortController = this.getOrCreateAbortController(assistantMessage.convId);
+ const streamCallbacks: ChatStreamCallbacks = {
+ onChunk: (chunk: string) => appendContentChunk(chunk),
+ onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
+ onToolCallChunk: (chunk: string) => {
+ const c = chunk.trim();
+ if (!c) return;
+ streamedToolCallContent = c;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
+ },
+ onAttachments: (extras: DatabaseMessageExtra[]) => {
+ if (!extras.length) return;
+ streamedExtras = [...streamedExtras, ...extras];
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras });
+ DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch(
+ console.error
+ );
+ },
+ onModel: (modelName: string) => recordModel(modelName),
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+ this.updateProcessingStateFromTimings(
+ {
+ prompt_n: timings?.prompt_n || 0,
+ prompt_ms: timings?.prompt_ms,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ cache_n: timings?.cache_n || 0,
+ prompt_progress: promptProgress
+ },
+ assistantMessage.convId
+ );
+ },
+ onComplete: async (
+ finalContent?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCallContent?: string
+ ) => {
+ this.setStreamingActive(false);
+ finalizeReasoning();
+ const combinedContent = hasStreamedChunks
+ ? streamedContent
+ : wrapReasoningContent(finalContent || '', reasoningContent);
+ const updateData: Record<string, unknown> = {
+ content: combinedContent,
+ toolCalls: toolCallContent || streamedToolCallContent,
+ timings
+ };
+ if (streamedExtras.length > 0) updateData.extra = streamedExtras;
+ if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
+ await DatabaseService.updateMessage(assistantMessage.id, updateData);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ const uiUpdate: Partial<DatabaseMessage> = {
+ content: combinedContent,
+ toolCalls: updateData.toolCalls as string
+ };
+ if (streamedExtras.length > 0) uiUpdate.extra = streamedExtras;
+ if (timings) uiUpdate.timings = timings;
+ if (resolvedModel) uiUpdate.model = resolvedModel;
+ conversationsStore.updateMessageAtIndex(idx, uiUpdate);
+ await conversationsStore.updateCurrentNode(assistantMessage.id);
+ if (onComplete) await onComplete(combinedContent);
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.setProcessingState(assistantMessage.convId, null);
+ if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
+ },
+ onError: (error: Error) => {
+ this.setStreamingActive(false);
+ if (isAbortError(error)) {
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.setProcessingState(assistantMessage.convId, null);
+ return;
+ }
+ console.error('Streaming error:', error);
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.setProcessingState(assistantMessage.convId, null);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ if (idx !== -1) {
+ const failedMessage = conversationsStore.removeMessageAtIndex(idx);
+ if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
+ }
const contextInfo = (
error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
).contextInfo;
-
- this.showErrorDialog(
- dialogType,
- error instanceof Error ? error.message : 'Unknown error',
+ this.showErrorDialog({
+ type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER,
+ message: error.message,
contextInfo
- );
+ });
+ if (onError) onError(error);
}
- }
+ };
+
+ const completionOptions = {
+ ...this.getApiOptions(),
+ ...(effectiveModel ? { model: effectiveModel } : {}),
+ ...streamCallbacks
+ };
+
+ await ChatService.sendMessage(
+ allMessages,
+ completionOptions,
+ assistantMessage.convId,
+ abortController.signal
+ );
}
async stopGeneration(): Promise<void> {
const activeConv = conversationsStore.activeConversation;
-
if (!activeConv) return;
-
await this.stopGenerationForChat(activeConv.id);
}
-
async stopGenerationForChat(convId: string): Promise<void> {
await this.savePartialResponseIfNeeded(convId);
-
- this.stopStreaming();
+ this.setStreamingActive(false);
this.abortRequest(convId);
this.setChatLoading(convId, false);
this.clearChatStreaming(convId);
- this.clearProcessingState(convId);
- }
-
- /**
- * Gets or creates an AbortController for a conversation
- */
- private getOrCreateAbortController(convId: string): AbortController {
- let controller = this.abortControllers.get(convId);
- if (!controller || controller.signal.aborted) {
- controller = new AbortController();
- this.abortControllers.set(convId, controller);
- }
- return controller;
+ this.setProcessingState(convId, null);
}
-
- /**
- * Aborts any ongoing request for a conversation
- */
- private abortRequest(convId?: string): void {
- if (convId) {
- const controller = this.abortControllers.get(convId);
- if (controller) {
- controller.abort();
- this.abortControllers.delete(convId);
- }
- } else {
- for (const controller of this.abortControllers.values()) {
- controller.abort();
- }
- this.abortControllers.clear();
- }
- }
-
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
const conversationId = convId || conversationsStore.activeConversation?.id;
-
if (!conversationId) return;
-
- const streamingState = this.chatStreamingStates.get(conversationId);
-
+ const streamingState = this.getChatStreaming(conversationId);
if (!streamingState || !streamingState.response.trim()) return;
-
const messages =
conversationId === conversationsStore.activeConversation?.id
? conversationsStore.activeMessages
: await conversationsStore.getConversationMessages(conversationId);
-
if (!messages.length) return;
-
const lastMessage = messages[messages.length - 1];
-
- if (lastMessage?.role === 'assistant') {
+ if (lastMessage?.role === MessageRole.ASSISTANT) {
try {
- const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = {
+ const updateData: { content: string; timings?: ChatMessageTimings } = {
content: streamingState.response
};
- if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking;
const lastKnownState = this.getProcessingState(conversationId);
if (lastKnownState) {
updateData.timings = {
: undefined
};
}
-
await DatabaseService.updateMessage(lastMessage.id, updateData);
-
- lastMessage.content = this.currentResponse;
-
- if (updateData.thinking) lastMessage.thinking = updateData.thinking;
-
+ lastMessage.content = streamingState.response;
if (updateData.timings) lastMessage.timings = updateData.timings;
} catch (error) {
- lastMessage.content = this.currentResponse;
+ lastMessage.content = streamingState.response;
console.error('Failed to save partial response:', error);
}
}
async updateMessage(messageId: string, newContent: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
- if (this.isLoading) this.stopGeneration();
-
- const result = this.getMessageByIdWithRole(messageId, 'user');
+ if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration();
+ const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
const { message: messageToUpdate, index: messageIndex } = result;
const originalContent = messageToUpdate.content;
-
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
-
conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseService.updateMessage(messageId, { content: newContent });
-
- if (isFirstUserMessage && newContent.trim()) {
+ if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationTitleWithConfirmation(
activeConv.id,
- newContent.trim(),
- conversationsStore.titleUpdateConfirmationCallback
+ newContent.trim()
);
- }
-
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
-
for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
-
conversationsStore.sliceActiveMessages(messageIndex + 1);
conversationsStore.updateConversationTimestamp();
-
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
-
const assistantMessage = await this.createAssistantMessage();
-
- if (!assistantMessage) throw new Error('Failed to create assistant message');
-
conversationsStore.addMessageToActive(assistantMessage);
-
await conversationsStore.updateCurrentNode(assistantMessage.id);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
}
);
} catch (error) {
- if (!this.isAbortError(error)) console.error('Failed to update message:', error);
+ if (!isAbortError(error)) console.error('Failed to update message:', error);
}
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Regeneration
- // ─────────────────────────────────────────────────────────────────────────────
-
async regenerateMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv || this.isLoading) return;
-
- const result = this.getMessageByIdWithRole(messageId, 'assistant');
+ if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+ const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: messageIndex } = result;
-
try {
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
conversationsStore.sliceActiveMessages(messageIndex);
conversationsStore.updateConversationTimestamp();
-
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
-
const parentMessageId =
conversationsStore.activeMessages.length > 0
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
: undefined;
const assistantMessage = await this.createAssistantMessage(parentMessageId);
- if (!assistantMessage) throw new Error('Failed to create assistant message');
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage
);
} catch (error) {
- if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
+ if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
+ this.setChatLoading(activeConv?.id || '', false);
+ }
+ }
+
+ async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+ try {
+ const idx = conversationsStore.findMessageIndex(messageId);
+ if (idx === -1) return;
+ const msg = conversationsStore.activeMessages[idx];
+ if (msg.role !== MessageRole.ASSISTANT) return;
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const parentMessage = allMessages.find((m) => m.id === msg.parent);
+ if (!parentMessage) return;
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+ const newAssistantMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: msg.convId,
+ type: msg.type,
+ timestamp: Date.now(),
+ role: msg.role,
+ content: '',
+ toolCalls: '',
+ children: [],
+ model: null
+ },
+ parentMessage.id
+ );
+ await conversationsStore.updateCurrentNode(newAssistantMessage.id);
+ conversationsStore.updateConversationTimestamp();
+ await conversationsStore.refreshActiveMessages();
+ const conversationPath = filterByLeafNodeId(
+ allMessages,
+ parentMessage.id,
+ false
+ ) as DatabaseMessage[];
+ const modelToUse = modelOverride || msg.model || undefined;
+ await this.streamChatCompletion(
+ conversationPath,
+ newAssistantMessage,
+ undefined,
+ undefined,
+ modelToUse
+ );
+ } catch (error) {
+ if (!isAbortError(error))
+ console.error('Failed to regenerate message with branching:', error);
this.setChatLoading(activeConv?.id || '', false);
}
}
const messageToDelete = allMessages.find((m) => m.id === messageId);
// For system messages, don't count descendants as they will be preserved (reparented to root)
- if (messageToDelete?.role === 'system') {
+ if (messageToDelete?.role === MessageRole.SYSTEM) {
const messagesToDelete = allMessages.filter((m) => m.id === messageId);
let userMessages = 0,
assistantMessages = 0;
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
- if (msg.role === 'user') {
+ if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
- } else if (msg.role === 'assistant') {
+ } else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
let userMessages = 0,
assistantMessages = 0;
const messageTypes: string[] = [];
+
for (const msg of messagesToDelete) {
- if (msg.role === 'user') {
+ if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
- } else if (msg.role === 'assistant') {
+ } else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
+
return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes };
}
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = allMessages.find((m) => m.id === messageId);
+
if (!messageToDelete) return;
const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
const latestSibling = siblings.reduce((latest, sibling) =>
sibling.timestamp > latest.timestamp ? sibling : latest
);
+
await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
} else if (messageToDelete.parent) {
await conversationsStore.updateCurrentNode(
);
}
}
+
await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
await conversationsStore.refreshActiveMessages();
}
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Editing
- // ─────────────────────────────────────────────────────────────────────────────
-
- clearEditMode(): void {
- this.isEditModeActive = false;
- this.addFilesHandler = null;
- }
-
async continueAssistantMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv || this.isLoading) return;
+ if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+ const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
- const result = this.getMessageByIdWithRole(messageId, 'assistant');
if (!result) return;
- const { message: msg, index: idx } = result;
- if (this.isChatLoading(activeConv.id)) return;
+ const { message: msg, index: idx } = result;
try {
- this.errorDialogState = null;
+ this.showErrorDialog(null);
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
if (!dbMessage) {
this.setChatLoading(activeConv.id, false);
-
return;
}
const originalContent = dbMessage.content;
- const originalThinking = dbMessage.thinking || '';
-
const conversationContext = conversationsStore.activeMessages.slice(0, idx);
const contextWithContinue = [
...conversationContext,
- { role: 'assistant' as const, content: originalContent }
+ { role: MessageRole.ASSISTANT as const, content: originalContent }
];
let appendedContent = '',
- appendedThinking = '',
- hasReceivedContent = false;
+ hasReceivedContent = false,
+ isReasoningOpen = hasUnclosedReasoningTag(originalContent);
+
+ const updateStreamingContent = (fullContent: string) => {
+ this.setChatStreaming(msg.convId, fullContent, msg.id);
+ conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
+ };
+
+ const appendContentChunk = (chunk: string) => {
+ if (isReasoningOpen) {
+ appendedContent += REASONING_TAGS.END;
+ isReasoningOpen = false;
+ }
+ appendedContent += chunk;
+ hasReceivedContent = true;
+ updateStreamingContent(originalContent + appendedContent);
+ };
+
+ const appendReasoningChunk = (chunk: string) => {
+ if (!isReasoningOpen) {
+ appendedContent += REASONING_TAGS.START;
+ isReasoningOpen = true;
+ }
+ appendedContent += chunk;
+ hasReceivedContent = true;
+ updateStreamingContent(originalContent + appendedContent);
+ };
+
+ const finalizeReasoning = () => {
+ if (isReasoningOpen) {
+ appendedContent += REASONING_TAGS.END;
+ isReasoningOpen = false;
+ }
+ };
const abortController = this.getOrCreateAbortController(msg.convId);
contextWithContinue,
{
...this.getApiOptions(),
-
- onChunk: (chunk: string) => {
- hasReceivedContent = true;
- appendedContent += chunk;
- const fullContent = originalContent + appendedContent;
- this.setChatStreaming(msg.convId, fullContent, msg.id);
- conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
- },
-
- onReasoningChunk: (reasoningChunk: string) => {
- hasReceivedContent = true;
- appendedThinking += reasoningChunk;
- conversationsStore.updateMessageAtIndex(idx, {
- thinking: originalThinking + appendedThinking
- });
- },
-
+ onChunk: (chunk: string) => appendContentChunk(chunk),
+ onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
msg.convId
);
},
-
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
- const fullContent = originalContent + (finalContent || appendedContent);
- const fullThinking = originalThinking + (reasoningContent || appendedThinking);
+ finalizeReasoning();
+
+ const appendedFromCompletion = hasReceivedContent
+ ? appendedContent
+ : wrapReasoningContent(finalContent || '', reasoningContent);
+ const fullContent = originalContent + appendedFromCompletion;
+
await DatabaseService.updateMessage(msg.id, {
content: fullContent,
- thinking: fullThinking,
timestamp: Date.now(),
timings
});
+
conversationsStore.updateMessageAtIndex(idx, {
content: fullContent,
- thinking: fullThinking,
timestamp: Date.now(),
timings
});
+
conversationsStore.updateConversationTimestamp();
+
this.setChatLoading(msg.convId, false);
this.clearChatStreaming(msg.convId);
- this.clearProcessingState(msg.convId);
+ this.setProcessingState(msg.convId, null);
},
-
onError: async (error: Error) => {
- if (this.isAbortError(error)) {
+ if (isAbortError(error)) {
if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
- thinking: originalThinking + appendedThinking,
timestamp: Date.now()
});
+
conversationsStore.updateMessageAtIndex(idx, {
content: originalContent + appendedContent,
- thinking: originalThinking + appendedThinking,
timestamp: Date.now()
});
}
+
this.setChatLoading(msg.convId, false);
this.clearChatStreaming(msg.convId);
- this.clearProcessingState(msg.convId);
+ this.setProcessingState(msg.convId, null);
+
return;
}
+
console.error('Continue generation error:', error);
- conversationsStore.updateMessageAtIndex(idx, {
- content: originalContent,
- thinking: originalThinking
- });
- await DatabaseService.updateMessage(msg.id, {
- content: originalContent,
- thinking: originalThinking
- });
+ conversationsStore.updateMessageAtIndex(idx, { content: originalContent });
+
+ await DatabaseService.updateMessage(msg.id, { content: originalContent });
+
this.setChatLoading(msg.convId, false);
this.clearChatStreaming(msg.convId);
- this.clearProcessingState(msg.convId);
- this.showErrorDialog(
- error.name === 'TimeoutError' ? 'timeout' : 'server',
- error.message
- );
+ this.setProcessingState(msg.convId, null);
+ this.showErrorDialog({
+ type:
+ error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER,
+ message: error.message
+ });
}
},
+
msg.convId,
abortController.signal
);
} catch (error) {
- if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
+ if (!isAbortError(error)) console.error('Failed to continue message:', error);
if (activeConv) this.setChatLoading(activeConv.id, false);
}
}
shouldBranch: boolean
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv || this.isLoading) return;
+ if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
- const result = this.getMessageByIdWithRole(messageId, 'assistant');
+ const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
+
const { message: msg, index: idx } = result;
try {
timestamp: Date.now(),
role: msg.role,
content: newContent,
- thinking: msg.thinking || '',
toolCalls: msg.toolCalls || '',
children: [],
model: msg.model
},
msg.parent!
);
+
await conversationsStore.updateCurrentNode(newMessage.id);
} else {
await DatabaseService.updateMessage(msg.id, { content: newContent });
await conversationsStore.updateCurrentNode(msg.id);
- conversationsStore.updateMessageAtIndex(idx, {
- content: newContent
- });
+ conversationsStore.updateMessageAtIndex(idx, { content: newContent });
}
+
conversationsStore.updateConversationTimestamp();
+
await conversationsStore.refreshActiveMessages();
} catch (error) {
console.error('Failed to edit assistant message:', error);
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
- const result = this.getMessageByIdWithRole(messageId, 'user');
+ const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
- const { message: msg, index: idx } = result;
+ const { message: msg, index: idx } = result;
try {
- const updateData: Partial<DatabaseMessage> = {
- content: newContent
- };
+ const updateData: Partial<DatabaseMessage> = { content: newContent };
- // Update extras if provided (including empty array to clear attachments)
- // Deep clone to avoid Proxy objects from Svelte reactivity
- if (newExtras !== undefined) {
- updateData.extra = JSON.parse(JSON.stringify(newExtras));
- }
+ if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras));
await DatabaseService.updateMessage(messageId, updateData);
+
conversationsStore.updateMessageAtIndex(idx, updateData);
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
await conversationsStore.updateConversationTitleWithConfirmation(
activeConv.id,
- newContent.trim(),
- conversationsStore.titleUpdateConfirmationCallback
+ newContent.trim()
);
}
+
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to edit user message:', error);
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
- if (!activeConv || this.isLoading) return;
-
- let result = this.getMessageByIdWithRole(messageId, 'user');
-
- if (!result) {
- result = this.getMessageByIdWithRole(messageId, 'system');
- }
-
+ if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;
+ let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
+ if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
if (!result) return;
const { message: msg } = result;
-
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage =
- msg.role === 'user' && rootMessage && msg.parent === rootMessage.id;
-
+ msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
const parentId = msg.parent || rootMessage?.id;
if (!parentId) return;
-
- // Use newExtras if provided, otherwise copy existing extras
- // Deep clone to avoid Proxy objects from Svelte reactivity
const extrasToUse =
newExtras !== undefined
? JSON.parse(JSON.stringify(newExtras))
: msg.extra
? JSON.parse(JSON.stringify(msg.extra))
: undefined;
-
const newMessage = await DatabaseService.createMessageBranch(
{
convId: msg.convId,
timestamp: Date.now(),
role: msg.role,
content: newContent,
- thinking: msg.thinking || '',
toolCalls: msg.toolCalls || '',
children: [],
extra: extrasToUse,
);
await conversationsStore.updateCurrentNode(newMessage.id);
conversationsStore.updateConversationTimestamp();
-
- if (isFirstUserMessage && newContent.trim()) {
+ if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationTitleWithConfirmation(
activeConv.id,
- newContent.trim(),
- conversationsStore.titleUpdateConfirmationCallback
+ newContent.trim()
);
- }
await conversationsStore.refreshActiveMessages();
-
- if (msg.role === 'user') {
- await this.generateResponseForMessage(newMessage.id);
- }
+ if (msg.role === MessageRole.USER) await this.generateResponseForMessage(newMessage.id);
} catch (error) {
console.error('Failed to edit message with branching:', error);
}
}
- async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
- const activeConv = conversationsStore.activeConversation;
- if (!activeConv || this.isLoading) return;
- try {
- const idx = conversationsStore.findMessageIndex(messageId);
- if (idx === -1) return;
- const msg = conversationsStore.activeMessages[idx];
- if (msg.role !== 'assistant') return;
-
- const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
- const parentMessage = allMessages.find((m) => m.id === msg.parent);
- if (!parentMessage) return;
-
- this.setChatLoading(activeConv.id, true);
- this.clearChatStreaming(activeConv.id);
-
- const newAssistantMessage = await DatabaseService.createMessageBranch(
- {
- convId: activeConv.id,
- type: 'text',
- timestamp: Date.now(),
- role: 'assistant',
- content: '',
- thinking: '',
- toolCalls: '',
- children: [],
- model: null
- },
- parentMessage.id
- );
- await conversationsStore.updateCurrentNode(newAssistantMessage.id);
- conversationsStore.updateConversationTimestamp();
- await conversationsStore.refreshActiveMessages();
-
- const conversationPath = filterByLeafNodeId(
- allMessages,
- parentMessage.id,
- false
- ) as DatabaseMessage[];
- // Use modelOverride if provided, otherwise use the original message's model
- // If neither is available, don't pass model (will use global selection)
- const modelToUse = modelOverride || msg.model || undefined;
- await this.streamChatCompletion(
- conversationPath,
- newAssistantMessage,
- undefined,
- undefined,
- modelToUse
- );
- } catch (error) {
- if (!this.isAbortError(error))
- console.error('Failed to regenerate message with branching:', error);
- this.setChatLoading(activeConv?.id || '', false);
- }
- }
-
private async generateResponseForMessage(userMessageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
-
if (!activeConv) return;
- this.errorDialogState = null;
+ this.showErrorDialog(null);
this.setChatLoading(activeConv.id, true);
this.clearChatStreaming(activeConv.id);
const assistantMessage = await DatabaseService.createMessageBranch(
{
convId: activeConv.id,
- type: 'text',
+ type: MessageType.TEXT,
timestamp: Date.now(),
- role: 'assistant',
+ role: MessageRole.ASSISTANT,
content: '',
- thinking: '',
toolCalls: '',
children: [],
model: null
},
userMessageId
);
+
conversationsStore.addMessageToActive(assistantMessage);
+
await this.streamChatCompletion(conversationPath, assistantMessage);
} catch (error) {
console.error('Failed to generate response:', error);
}
}
- getAddFilesHandler(): ((files: File[]) => void) | null {
- return this.addFilesHandler;
- }
-
- savePendingDraft(message: string, files: ChatUploadedFile[]): void {
- this._pendingDraftMessage = message;
- this._pendingDraftFiles = [...files];
- }
+ private getContextTotal(): number | null {
+ const activeConvId = this.activeConversationId;
+ const activeState = activeConvId ? this.getProcessingState(activeConvId) : null;
- consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
- if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) {
- return null;
- }
+ if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0)
+ return activeState.contextTotal;
- const draft = {
- message: this._pendingDraftMessage,
- files: [...this._pendingDraftFiles]
- };
+ if (isRouterMode()) {
+ const modelContextSize = selectedModelContextSize();
- this._pendingDraftMessage = '';
- this._pendingDraftFiles = [];
+ if (typeof modelContextSize === 'number' && modelContextSize > 0) {
+ return modelContextSize;
+ }
+ } else {
+ const propsContextSize = contextSize();
- return draft;
- }
+ if (typeof propsContextSize === 'number' && propsContextSize > 0) {
+ return propsContextSize;
+ }
+ }
- hasPendingDraft(): boolean {
- return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0;
+ return null;
}
- public getAllLoadingChats(): string[] {
- return Array.from(this.chatLoadingStates.keys());
- }
+ updateProcessingStateFromTimings(
+ timingData: {
+ prompt_n: number;
+ prompt_ms?: number;
+ predicted_n: number;
+ predicted_per_second: number;
+ cache_n: number;
+ prompt_progress?: ChatMessagePromptProgress;
+ },
+ conversationId?: string
+ ): void {
+ const processingState = this.parseTimingData(timingData);
- public getAllStreamingChats(): string[] {
- return Array.from(this.chatStreamingStates.keys());
- }
+ if (processingState === null) {
+ console.warn('Failed to parse timing data - skipping update');
+ return;
+ }
- public getChatStreamingPublic(
- convId: string
- ): { response: string; messageId: string } | undefined {
- return this.getChatStreaming(convId);
+ const targetId = conversationId || this.activeConversationId;
+ if (targetId) {
+ this.setProcessingState(targetId, processingState);
+ }
}
- public isChatLoadingPublic(convId: string): boolean {
- return this.isChatLoading(convId);
+ private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
+ const promptTokens = (timingData.prompt_n as number) || 0,
+ promptMs = (timingData.prompt_ms as number) || undefined,
+ predictedTokens = (timingData.predicted_n as number) || 0,
+ tokensPerSecond = (timingData.predicted_per_second as number) || 0,
+ cacheTokens = (timingData.cache_n as number) || 0;
+ const promptProgress = timingData.prompt_progress as
+ | { total: number; cache: number; processed: number; time_ms: number }
+ | undefined;
+ const contextTotal = this.getContextTotal();
+ const currentConfig = config();
+ const outputTokensMax = currentConfig.max_tokens || -1;
+ const contextUsed = promptTokens + cacheTokens + predictedTokens,
+ outputTokensUsed = predictedTokens;
+ const progressCache = promptProgress?.cache || 0,
+ progressActualDone = (promptProgress?.processed ?? 0) - progressCache,
+ progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
+ const progressPercent = promptProgress
+ ? Math.round((progressActualDone / progressActualTotal) * 100)
+ : undefined;
+ return {
+ status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
+ tokensDecoded: predictedTokens,
+ tokensRemaining: outputTokensMax - predictedTokens,
+ contextUsed,
+ contextTotal,
+ outputTokensUsed,
+ outputTokensMax,
+ hasNextToken: predictedTokens > 0,
+ tokensPerSecond,
+ temperature: currentConfig.temperature ?? 0.8,
+ topP: currentConfig.top_p ?? 0.95,
+ speculative: false,
+ progressPercent,
+ promptProgress,
+ promptTokens,
+ promptMs,
+ cacheTokens
+ };
}
- isEditing(): boolean {
- return this.isEditModeActive;
+ restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === MessageRole.ASSISTANT && message.timings) {
+ const restoredState = this.parseTimingData({
+ prompt_n: message.timings.prompt_n || 0,
+ prompt_ms: message.timings.prompt_ms,
+ predicted_n: message.timings.predicted_n || 0,
+ predicted_per_second:
+ message.timings.predicted_n && message.timings.predicted_ms
+ ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+ : 0,
+ cache_n: message.timings.cache_n || 0
+ });
+ if (restoredState) {
+ this.setProcessingState(conversationId, restoredState);
+ return;
+ }
+ }
+ }
}
- setEditModeActive(handler: (files: File[]) => void): void {
- this.isEditModeActive = true;
- this.addFilesHandler = handler;
+ getConversationModel(messages: DatabaseMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
+ }
+ return null;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Utilities
- // ─────────────────────────────────────────────────────────────────────────────
-
private getApiOptions(): Record<string, unknown> {
const currentConfig = config();
const hasValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== '';
-
const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
- // Model selection (required in ROUTER mode)
if (isRouterMode()) {
const modelName = selectedModelName();
if (modelName) apiOptions.model = modelName;
}
- // Config options needed by ChatService
if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage;
+
if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true;
if (hasValue(currentConfig.temperature))
apiOptions.temperature = Number(currentConfig.temperature);
+
if (hasValue(currentConfig.max_tokens))
apiOptions.max_tokens = Number(currentConfig.max_tokens);
+
if (hasValue(currentConfig.dynatemp_range))
apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
+
if (hasValue(currentConfig.dynatemp_exponent))
apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
+
if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k);
+
if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p);
+
if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p);
+
if (hasValue(currentConfig.xtc_probability))
apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
+
if (hasValue(currentConfig.xtc_threshold))
apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
+
if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p);
+
if (hasValue(currentConfig.repeat_last_n))
apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
+
if (hasValue(currentConfig.repeat_penalty))
apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
+
if (hasValue(currentConfig.presence_penalty))
apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
+
if (hasValue(currentConfig.frequency_penalty))
apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
+
if (hasValue(currentConfig.dry_multiplier))
apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
+
if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base);
+
if (hasValue(currentConfig.dry_allowed_length))
apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
+
if (hasValue(currentConfig.dry_penalty_last_n))
apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
+
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
+
if (currentConfig.backend_sampling)
apiOptions.backend_sampling = currentConfig.backend_sampling;
+
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
return apiOptions;
export const chatStore = new ChatStore();
export const activeProcessingState = () => chatStore.activeProcessingState;
-export const clearEditMode = () => chatStore.clearEditMode();
export const currentResponse = () => chatStore.currentResponse;
export const errorDialog = () => chatStore.errorDialogState;
export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
export const isChatStreaming = () => chatStore.isStreaming();
export const isEditing = () => chatStore.isEditing();
export const isLoading = () => chatStore.isLoading;
-export const setEditModeActive = (handler: (files: File[]) => void) =>
- chatStore.setEditModeActive(handler);
export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
-export const clearPendingEditMessageId = () => (chatStore.pendingEditMessageId = null);
-export const removeSystemPromptPlaceholder = (messageId: string) =>
- chatStore.removeSystemPromptPlaceholder(messageId);
-import { browser } from '$app/environment';
-import { goto } from '$app/navigation';
-import { toast } from 'svelte-sonner';
-import { DatabaseService } from '$lib/services/database.service';
-import { config } from '$lib/stores/settings.svelte';
-import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
-import { AttachmentType } from '$lib/enums';
-
/**
- * conversationsStore - Persistent conversation data and lifecycle management
+ * conversationsStore - Reactive State Store for Conversations
*
- * **Terminology - Chat vs Conversation:**
- * - **Chat**: The active interaction space with the Chat Completions API. Represents the
- * real-time streaming session, loading states, and UI visualization of AI communication.
- * Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
- * - **Conversation**: The persistent database entity storing all messages and metadata.
- * A "conversation" survives across sessions, page reloads, and browser restarts.
- * It contains the complete message history, branching structure, and conversation metadata.
- *
- * This store manages all conversation-level data and operations including creation, loading,
- * deletion, and navigation. It maintains the list of conversations and the currently active
- * conversation with its message history, providing reactive state for UI components.
+ * Manages conversation lifecycle, persistence, navigation.
*
* **Architecture & Relationships:**
- * - **conversationsStore** (this class): Persistent conversation data management
- * - Manages conversation list and active conversation state
- * - Handles conversation CRUD operations via DatabaseService
- * - Maintains active message array for current conversation
- * - Coordinates branching navigation (currNode tracking)
- *
- * - **chatStore**: Uses conversation data as context for active AI streaming
- * - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
+ * - **DatabaseService**: Stateless IndexedDB layer
+ * - **conversationsStore** (this): Reactive state + business logic
+ * - **chatStore**: Chat-specific state (streaming, loading)
*
- * **Key Features:**
- * - **Conversation Lifecycle**: Create, load, update, delete conversations
- * - **Message Management**: Active message array with branching support
- * - **Import/Export**: JSON-based conversation backup and restore
- * - **Branch Navigation**: Navigate between message tree branches
- * - **Title Management**: Auto-update titles with confirmation dialogs
- * - **Reactive State**: Svelte 5 runes for automatic UI updates
+ * **Key Responsibilities:**
+ * - Conversation CRUD (create, load, delete)
+ * - Message management and tree navigation
+ * - Import/Export functionality
+ * - Title management with confirmation
*
- * **State Properties:**
- * - `conversations`: All conversations sorted by last modified
- * - `activeConversation`: Currently viewed conversation
- * - `activeMessages`: Messages in current conversation path
- * - `isInitialized`: Store initialization status
+ * @see DatabaseService in services/database.ts for IndexedDB operations
*/
+
+import { goto } from '$app/navigation';
+import { browser } from '$app/environment';
+import { toast } from 'svelte-sonner';
+import { DatabaseService } from '$lib/services/database.service';
+import { config } from '$lib/stores/settings.svelte';
+import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
+import { MessageRole } from '$lib/enums';
+
class ConversationsStore {
- // ─────────────────────────────────────────────────────────────────────────────
- // State
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * State
+ *
+ *
+ */
/** List of all conversations */
conversations = $state<DatabaseConversation[]>([]);
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
- // ─────────────────────────────────────────────────────────────────────────────
- // Modalities
- // ─────────────────────────────────────────────────────────────────────────────
-
/**
- * Modalities used in the active conversation.
- * Computed from attachments in activeMessages.
- * Used to filter available models - models must support all used modalities.
+ *
+ *
+ * Lifecycle
+ *
+ *
*/
- usedModalities: ModelModalities = $derived.by(() => {
- return this.calculateModalitiesFromMessages(this.activeMessages);
- });
/**
- * Calculate modalities from a list of messages.
- * Helper method used by both usedModalities and getModalitiesUpToMessage.
+ * Initialize the store by loading conversations from database.
+ * Must be called once after app startup.
*/
- private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
- const modalities: ModelModalities = { vision: false, audio: false };
+ async init(): Promise<void> {
+ if (!browser) return;
+ if (this.isInitialized) return;
- for (const message of messages) {
- if (!message.extra) continue;
-
- for (const extra of message.extra) {
- if (extra.type === AttachmentType.IMAGE) {
- modalities.vision = true;
- }
+ try {
+ await this.loadConversations();
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('Failed to initialize conversations:', error);
+ }
+ }
- // PDF only requires vision if processed as images
- if (extra.type === AttachmentType.PDF) {
- const pdfExtra = extra as DatabaseMessageExtraPdfFile;
+ /**
+ * Alias for init() for backward compatibility.
+ */
+ async initialize(): Promise<void> {
+ return this.init();
+ }
- if (pdfExtra.processedAsImages) {
- modalities.vision = true;
- }
- }
+ /**
+ *
+ *
+ * Message Array Operations
+ *
+ *
+ */
- if (extra.type === AttachmentType.AUDIO) {
- modalities.audio = true;
- }
- }
+ /**
+ * Adds a message to the active messages array
+ */
+ addMessageToActive(message: DatabaseMessage): void {
+ this.activeMessages.push(message);
+ }
- if (modalities.vision && modalities.audio) break;
+ /**
+ * Updates a message at a specific index in active messages
+ */
+ updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
+ if (index !== -1 && this.activeMessages[index]) {
+ this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
}
-
- return modalities;
}
/**
- * Get modalities used in messages BEFORE the specified message.
- * Used for regeneration - only consider context that was available when generating this message.
+ * Finds the index of a message in active messages
*/
- getModalitiesUpToMessage(messageId: string): ModelModalities {
- const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
-
- if (messageIndex === -1) {
- return this.usedModalities;
- }
+ findMessageIndex(messageId: string): number {
+ return this.activeMessages.findIndex((m) => m.id === messageId);
+ }
- const messagesBefore = this.activeMessages.slice(0, messageIndex);
- return this.calculateModalitiesFromMessages(messagesBefore);
+ /**
+ * Removes messages from active messages starting at an index
+ */
+ sliceActiveMessages(startIndex: number): void {
+ this.activeMessages = this.activeMessages.slice(0, startIndex);
}
- constructor() {
- if (browser) {
- this.initialize();
+ /**
+ * Removes a message from active messages by index
+ */
+ removeMessageAtIndex(index: number): DatabaseMessage | undefined {
+ if (index !== -1) {
+ return this.activeMessages.splice(index, 1)[0];
}
+ return undefined;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Lifecycle
- // ─────────────────────────────────────────────────────────────────────────────
-
/**
- * Initializes the conversations store by loading conversations from the database
+ * Sets the callback function for title update confirmations
*/
- async initialize(): Promise<void> {
- try {
- await this.loadConversations();
- this.isInitialized = true;
- } catch (error) {
- console.error('Failed to initialize conversations store:', error);
- }
+ setTitleUpdateConfirmationCallback(
+ callback: (currentTitle: string, newTitle: string) => Promise<boolean>
+ ): void {
+ this.titleUpdateConfirmationCallback = callback;
}
+ /**
+ *
+ *
+ * Conversation CRUD
+ *
+ *
+ */
+
/**
* Loads all conversations from the database
*/
async loadConversations(): Promise<void> {
- this.conversations = await DatabaseService.getAllConversations();
+ const conversations = await DatabaseService.getAllConversations();
+ this.conversations = conversations;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Conversation CRUD
- // ─────────────────────────────────────────────────────────────────────────────
-
/**
* Creates a new conversation and navigates to it
* @param name - Optional name for the conversation
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName);
- this.conversations.unshift(conversation);
+ this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
this.activeMessages = [];
if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId);
- this.activeMessages = filterByLeafNodeId(
+ const filteredMessages = filterByLeafNodeId(
allMessages,
conversation.currNode,
false
) as DatabaseMessage[];
+ this.activeMessages = filteredMessages;
} else {
- this.activeMessages = await DatabaseService.getConversationMessages(convId);
+ const messages = await DatabaseService.getConversationMessages(convId);
+ this.activeMessages = messages;
}
return true;
}
/**
- * Clears the active conversation and messages
- * Used when navigating away from chat or starting fresh
+ * Clears the active conversation and messages.
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
- // Active processing conversation is now managed by chatStore
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Message Management
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ * Deletes a conversation and all its messages
+ * @param convId - The conversation ID to delete
+ */
+ async deleteConversation(convId: string): Promise<void> {
+ try {
+ await DatabaseService.deleteConversation(convId);
+
+ this.conversations = this.conversations.filter((c) => c.id !== convId);
+
+ if (this.activeConversation?.id === convId) {
+ this.clearActiveConversation();
+ await goto(`?new_chat=true#/`);
+ }
+ } catch (error) {
+ console.error('Failed to delete conversation:', error);
+ }
+ }
/**
- * Refreshes active messages based on currNode after branch navigation
+ * Deletes all conversations and their messages
+ */
+ async deleteAll(): Promise<void> {
+ try {
+ const allConversations = await DatabaseService.getAllConversations();
+
+ for (const conv of allConversations) {
+ await DatabaseService.deleteConversation(conv.id);
+ }
+
+ this.clearActiveConversation();
+ this.conversations = [];
+
+ toast.success('All conversations deleted');
+
+ await goto(`?new_chat=true#/`);
+ } catch (error) {
+ console.error('Failed to delete all conversations:', error);
+ toast.error('Failed to delete conversations');
+ }
+ }
+
+ /**
+ *
+ *
+ * Message Management
+ *
+ *
+ */
+
+ /**
+ * Refreshes active messages based on currNode after branch navigation.
*/
async refreshActiveMessages(): Promise<void> {
if (!this.activeConversation) return;
const leafNodeId =
this.activeConversation.currNode ||
- allMessages.reduce((latest: DatabaseMessage, msg: DatabaseMessage) =>
- msg.timestamp > latest.timestamp ? msg : latest
- ).id;
+ allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
- this.activeMessages.length = 0;
- this.activeMessages.push(...currentPath);
+ this.activeMessages = currentPath;
+ }
+
+ /**
+ * Gets all messages for a specific conversation
+ * @param convId - The conversation ID
+ * @returns Array of messages
+ */
+ async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
+ return await DatabaseService.getConversationMessages(convId);
}
/**
- * Updates the name of a conversation
+ *
+ *
+ * Title Management
+ *
+ *
+ */
+
+ /**
+ * Updates the name of a conversation.
* @param convId - The conversation ID to update
* @param name - The new name for the conversation
*/
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
+ this.conversations = [...this.conversations];
}
if (this.activeConversation?.id === convId) {
- this.activeConversation.name = name;
+ this.activeConversation = { ...this.activeConversation, name };
}
} catch (error) {
console.error('Failed to update conversation name:', error);
* Updates conversation title with optional confirmation dialog based on settings
* @param convId - The conversation ID to update
* @param newTitle - The new title content
- * @param onConfirmationNeeded - Callback when user confirmation is needed
* @returns True if title was updated, false if cancelled
*/
async updateConversationTitleWithConfirmation(
convId: string,
- newTitle: string,
- onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
+ newTitle: string
): Promise<boolean> {
try {
const currentConfig = config();
- if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
+ if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) return false;
- const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
+ const shouldUpdate = await this.titleUpdateConfirmationCallback(
+ conversation.name,
+ newTitle
+ );
if (!shouldUpdate) return false;
}
}
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Navigation
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Updates the current node of the active conversation
- * @param nodeId - The new current node ID
- */
- async updateCurrentNode(nodeId: string): Promise<void> {
- if (!this.activeConversation) return;
-
- await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
- this.activeConversation.currNode = nodeId;
- }
-
/**
* Updates conversation lastModified timestamp and moves it to top of list
*/
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = Date.now();
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
- this.conversations.unshift(updatedConv);
+ this.conversations = [updatedConv, ...this.conversations];
}
}
/**
- * Navigates to a specific sibling branch by updating currNode and refreshing messages
+ * Updates the current node of the active conversation
+ * @param nodeId - The new current node ID
+ */
+ async updateCurrentNode(nodeId: string): Promise<void> {
+ if (!this.activeConversation) return;
+
+ await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
+ this.activeConversation = { ...this.activeConversation, currNode: nodeId };
+ }
+
+ /**
+ *
+ *
+ * Branch Navigation
+ *
+ *
+ */
+
+ /**
+ * Navigates to a specific sibling branch by updating currNode and refreshing messages.
* @param siblingId - The sibling message ID to navigate to
*/
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
- const rootMessage = allMessages.find(
- (m: DatabaseMessage) => m.type === 'root' && m.parent === null
- );
+ const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const currentFirstUserMessage = this.activeMessages.find(
- (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage?.id
+ (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
- this.activeConversation.currNode = currentLeafNodeId;
+ this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
await this.refreshActiveMessages();
- // Only show title dialog if we're navigating between different first user message siblings
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
- (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
+ (m) => m.role === MessageRole.USER && m.parent === rootMessage.id
);
if (
) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
- newFirstUserMessage.content.trim(),
- this.titleUpdateConfirmationCallback
+ newFirstUserMessage.content.trim()
);
}
}
}
/**
- * Deletes a conversation and all its messages
- * @param convId - The conversation ID to delete
+ *
+ *
+ * Import & Export
+ *
+ *
*/
- async deleteConversation(convId: string): Promise<void> {
- try {
- await DatabaseService.deleteConversation(convId);
-
- this.conversations = this.conversations.filter((c) => c.id !== convId);
-
- if (this.activeConversation?.id === convId) {
- this.clearActiveConversation();
- await goto(`?new_chat=true#/`);
- }
- } catch (error) {
- console.error('Failed to delete conversation:', error);
- }
- }
-
- /**
- * Deletes all conversations and their messages
- */
- async deleteAll(): Promise<void> {
- try {
- const allConversations = await DatabaseService.getAllConversations();
-
- for (const conv of allConversations) {
- await DatabaseService.deleteConversation(conv.id);
- }
-
- this.clearActiveConversation();
- this.conversations = [];
-
- toast.success('All conversations deleted');
-
- await goto(`?new_chat=true#/`);
- } catch (error) {
- console.error('Failed to delete all conversations:', error);
- toast.error('Failed to delete conversations');
- }
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Import/Export
- // ─────────────────────────────────────────────────────────────────────────────
/**
- * Downloads a conversation as JSON file
+ * Downloads a conversation as JSON file.
* @param convId - The conversation ID to download
*/
async downloadConversation(convId: string): Promise<void> {
}
const allData = await Promise.all(
- allConversations.map(async (conv: DatabaseConversation) => {
+ allConversations.map(async (conv) => {
const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv, messages };
})
});
}
- /**
- * Gets all messages for a specific conversation
- * @param convId - The conversation ID
- * @returns Array of messages
- */
- async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
- return await DatabaseService.getConversationMessages(convId);
- }
-
/**
* Imports conversations from provided data (without file picker)
* @param data - Array of conversation data with messages
return result;
}
- /**
- * Adds a message to the active messages array
- * Used by chatStore when creating new messages
- * @param message - The message to add
- */
- addMessageToActive(message: DatabaseMessage): void {
- this.activeMessages.push(message);
- }
-
- /**
- * Updates a message at a specific index in active messages
- * Creates a new object to trigger Svelte 5 reactivity
- * @param index - The index of the message to update
- * @param updates - Partial message data to update
- */
- updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
- if (index !== -1 && this.activeMessages[index]) {
- // Create new object to trigger Svelte 5 reactivity
- this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
- }
- }
-
- /**
- * Finds the index of a message in active messages
- * @param messageId - The message ID to find
- * @returns The index of the message, or -1 if not found
- */
- findMessageIndex(messageId: string): number {
- return this.activeMessages.findIndex((m) => m.id === messageId);
- }
-
- /**
- * Removes messages from active messages starting at an index
- * @param startIndex - The index to start removing from
- */
- sliceActiveMessages(startIndex: number): void {
- this.activeMessages = this.activeMessages.slice(0, startIndex);
- }
-
- /**
- * Removes a message from active messages by index
- * @param index - The index to remove
- * @returns The removed message or undefined
- */
- removeMessageAtIndex(index: number): DatabaseMessage | undefined {
- if (index !== -1) {
- return this.activeMessages.splice(index, 1)[0];
- }
- return undefined;
- }
-
/**
* Triggers file download in browser
- * @param data - The data to download
- * @param filename - Optional filename for the download
*/
private triggerDownload(data: ExportedConversations, filename?: string): void {
const conversation =
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Utilities
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Sets the callback function for title update confirmations
- * @param callback - Function to call when confirmation is needed
- */
- setTitleUpdateConfirmationCallback(
- callback: (currentTitle: string, newTitle: string) => Promise<boolean>
- ): void {
- this.titleUpdateConfirmationCallback = callback;
- }
}
export const conversationsStore = new ConversationsStore();
+// Auto-initialize in browser
+if (browser) {
+ conversationsStore.init();
+}
+
export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;
export const isConversationsInitialized = () => conversationsStore.isInitialized;
-export const usedModalities = () => conversationsStore.usedModalities;
import { SvelteSet } from 'svelte/reactivity';
-import { ModelsService } from '$lib/services/models.service';
-import { PropsService } from '$lib/services/props.service';
import { ServerModelStatus, ModelModality } from '$lib/enums';
+import { ModelsService, PropsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte';
+import { TTLCache } from '$lib/utils';
+import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
* - **Lazy loading**: ensureModelLoaded() loads models on demand
*/
class ModelsStore {
- // ─────────────────────────────────────────────────────────────────────────────
- // State
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * State
+ *
+ *
+ */
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
/**
- * Model-specific props cache
+ * Model-specific props cache with TTL
* Key: modelId, Value: props data including modalities
+ * TTL: 10 minutes - props don't change frequently
*/
- private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
+ private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
+ ttlMs: MODEL_PROPS_CACHE_TTL_MS,
+ maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES
+ });
private modelPropsFetching = $state<Set<string>>(new Set());
/**
*/
propsCacheVersion = $state(0);
- // ─────────────────────────────────────────────────────────────────────────────
- // Computed Getters
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Computed Getters
+ *
+ *
+ */
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Modalities
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Modalities
+ *
+ *
+ */
/**
* Get modalities for a specific model
* Returns cached modalities from model props
*/
getModelModalities(modelId: string): ModelModalities | null {
- // First check if modalities are stored in the model option
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
- // Fall back to props cache
const props = this.modelPropsCache.get(modelId);
if (props?.modalities) {
return {
* Get props for a specific model (from cache)
*/
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
- return this.modelPropsCache.get(modelId) ?? null;
+ return this.modelPropsCache.get(modelId);
}
/**
* Get context size (n_ctx) for a specific model from cached props
*/
getModelContextSize(modelId: string): number | null {
- const props = this.modelPropsCache.get(modelId);
- return props?.default_generation_settings?.n_ctx ?? null;
+ const props = this.getModelProps(modelId);
+ const nCtx = props?.default_generation_settings?.n_ctx;
+
+ return typeof nCtx === 'number' ? nCtx : null;
}
/**
return this.modelPropsFetching.has(modelId);
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Status Queries
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Status Queries
+ *
+ *
+ */
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return usage !== undefined && usage.size > 0;
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Data Fetching
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Data Fetching
+ *
+ *
+ */
/**
* Fetch list of models from server and detect server role
this.error = null;
try {
- // Ensure server props are loaded (for role detection and MODEL mode modalities)
if (!serverStore.props) {
await serverStore.fetch();
}
this.models = models;
- // In MODEL mode, populate modalities from serverStore.props (single model)
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props;
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false
};
- // Cache props for the single model
this.modelPropsCache.set(this.models[0].model, serverProps);
- // Update model with modalities
this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model
);
* @returns Props data or null if fetch failed or model not loaded
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
- // Return cached props if available
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
return null;
}
- // Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null;
this.modelPropsFetching.add(modelId);
const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return;
- // Fetch props for each loaded model in parallel
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
return { ...model, modalities };
});
- // Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
model.model === modelId ? { ...model, modalities } : model
);
- // Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Model Selection
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Model Selection
+ *
+ *
+ */
/**
* Select a model for new conversations
return this.models.some((model) => model.model === modelName);
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Loading/Unloading Models
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Loading/Unloading Models
+ *
+ *
+ */
/**
* WORKAROUND: Polling for model status after load/unload operations.
return;
}
- // Wait before next poll
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
}
try {
await ModelsService.load(modelId);
-
- // Poll until model is loaded
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
await this.loadModel(modelId);
}
- // ─────────────────────────────────────────────────────────────────────────────
- // Utilities
- // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ *
+ *
+ * Utilities
+ *
+ *
+ */
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
this.modelPropsCache.clear();
this.modelPropsFetching.clear();
}
+
+ /**
+ * Prune expired entries from caches.
+ * Call periodically for proactive memory cleanup.
+ */
+ pruneExpiredCache(): number {
+ return this.modelPropsCache.prune();
+ }
}
export const modelsStore = new ModelsStore();
import type { ErrorDialogType } from '$lib/enums';
-import type { DatabaseMessage, DatabaseMessageExtra } from './database';
-
-export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
-export type ChatRole = 'user' | 'assistant' | 'system';
+import type { DatabaseMessageExtra } from './database';
export interface ChatUploadedFile {
id: string;
prompt_n?: number;
}
+/**
+ * Callbacks for streaming chat responses
+ */
export interface ChatStreamCallbacks {
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onError?: (error: Error) => void;
}
+/**
+ * Error dialog state for displaying server/timeout errors
+ */
export interface ErrorDialogState {
type: ErrorDialogType;
message: string;
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
}
+/**
+ * Live processing stats during prompt evaluation
+ */
export interface LiveProcessingStats {
tokensProcessed: number;
totalTokens: number;
etaSecs?: number;
}
+/**
+ * Live generation stats during token generation
+ */
export interface LiveGenerationStats {
tokensGenerated: number;
timeMs: number;
tokensPerSecond: number;
}
+/**
+ * Options for getting attachment display items
+ */
export interface AttachmentDisplayItemsOptions {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
}
+/**
+ * Result of file processing operation
+ */
export interface FileProcessingResult {
extras: DatabaseMessageExtra[];
emptyFiles: string[];
import type { AttachmentType } from '$lib/enums';
+/**
+ * Common utility types used across the application
+ */
+
/**
* Represents a key-value pair.
+ * Used for headers, environment variables, query parameters, etc.
*/
export interface KeyValuePair {
key: string;
}
/**
- * Binary detection configuration options.
+ * Binary detection configuration options
*/
export interface BinaryDetectionOptions {
+ /** Number of characters to check from the beginning of the file */
prefixLength: number;
+ /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */
suspiciousCharThresholdRatio: number;
+ /** Maximum absolute number of null bytes allowed */
maxAbsoluteNullBytes: number;
}
/**
- * Format for text attachments when copied to clipboard.
+ * Format for text attachments when copied to clipboard
*/
export interface ClipboardTextAttachment {
type: typeof AttachmentType.TEXT;
message: string;
textAttachments: ClipboardTextAttachment[];
}
+
+export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
type: AttachmentType.PDF;
base64Data: string;
name: string;
- content: string; // Text content extracted from PDF
- images?: string[]; // Optional: PDF pages as base64 images
- processedAsImages: boolean; // Whether PDF was processed as images
+ content: string;
+ images?: string[];
+ processedAsImages: boolean;
}
export interface DatabaseMessageExtraTextFile {
timestamp: number;
role: ChatRole;
content: string;
- parent: string;
- thinking: string;
+ parent: string | null;
+ /**
+ * @deprecated - left for backward compatibility
+ */
+ thinking?: string;
+ /** Serialized JSON array of tool calls made by assistant messages */
toolCalls?: string;
+ /** Tool call ID for tool result messages (role: 'tool') */
+ toolCallId?: string;
children: string[];
extra?: DatabaseMessageExtra[];
timings?: ChatMessageTimings;
model?: string;
}
-/**
- * Represents a single conversation with its associated messages,
- * typically used for import/export operations.
- */
export type ExportedConversation = {
conv: DatabaseConversation;
messages: DatabaseMessage[];
};
-/**
- * Type representing one or more exported conversations.
- * Can be a single conversation object or an array of them.
- */
export type ExportedConversations = ExportedConversation | ExportedConversation[];
// Chat types
export type {
- ChatMessageType,
- ChatRole,
ChatUploadedFile,
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
LiveGenerationStats,
AttachmentDisplayItemsOptions,
FileProcessingResult
-} from './chat';
+} from './chat.d';
// Database types
export type {
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
-import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
import type { DatabaseMessageExtra } from './database';
+import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
export type SettingsConfigValue = string | number | boolean;
[key: string]: SettingsConfigValue;
};
+/**
+ * Parameter synchronization types for server defaults and user overrides
+ * Note: ParameterSource and SyncableParameterType enums are imported from '$lib/enums'
+ */
export type ParameterValue = string | number | boolean;
export type ParameterRecord = Record<string, ParameterValue>;
export interface ParameterInfo {
- value: ParameterValue;
+ value: string | number | boolean;
source: ParameterSource;
- serverDefault?: ParameterValue;
- userOverride?: ParameterValue;
+ serverDefault?: string | number | boolean;
+ userOverride?: string | number | boolean;
}
export interface SyncableParameter {
import type {
DatabaseMessageExtra,
DatabaseMessageExtraTextFile,
- DatabaseMessageExtraLegacyContext
-} from '$lib/types/database';
+ DatabaseMessageExtraLegacyContext,
+ ClipboardTextAttachment,
+ ParsedClipboardContent
+} from '$lib/types';
/**
* Copy text to clipboard with toast notification
return copyToClipboard(rawCode, successMessage, errorMessage);
}
-/**
- * Format for text attachments when copied to clipboard
- */
-export interface ClipboardTextAttachment {
- type: typeof AttachmentType.TEXT;
- name: string;
- content: string;
-}
-
-/**
- * Parsed result from clipboard content
- */
-export interface ParsedClipboardContent {
- message: string;
- textAttachments: ClipboardTextAttachment[];
-}
-
/**
* Formats a message with text attachments for clipboard copying.
*
import { getFileTypeCategory } from '$lib/utils';
import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';
+import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types';
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
});
}
-export interface FileProcessingResult {
- extras: DatabaseMessageExtra[];
- emptyFiles: string[];
-}
-
export async function parseFilesToMessageExtras(
files: ChatUploadedFile[],
activeModelId?: string
+import {
+ MS_PER_SECOND,
+ SECONDS_PER_MINUTE,
+ SECONDS_PER_HOUR,
+ SHORT_DURATION_THRESHOLD,
+ MEDIUM_DURATION_THRESHOLD
+} from '$lib/constants/formatters';
+
/**
* Formats file size in bytes to human readable format
* Supports Bytes, KB, MB, and GB
export function formatPerformanceTime(ms: number): string {
if (ms < 0) return '0s';
- const totalSeconds = ms / 1000;
+ const totalSeconds = ms / MS_PER_SECOND;
- if (totalSeconds < 1) {
+ if (totalSeconds < SHORT_DURATION_THRESHOLD) {
return `${totalSeconds.toFixed(1)}s`;
}
- if (totalSeconds < 10) {
+ if (totalSeconds < MEDIUM_DURATION_THRESHOLD) {
return `${totalSeconds.toFixed(1)}s`;
}
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = Math.floor(totalSeconds % 60);
+ const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
+ const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
+ const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
const parts: string[] = [];
return parts.join(' ');
}
+
+/**
+ * Formats attachment content for API requests with consistent header style.
+ * Used when converting message attachments to text content parts.
+ *
+ * @param label - Type label (e.g., 'File', 'PDF File', 'MCP Prompt')
+ * @param name - File or attachment name
+ * @param content - The actual content to include
+ * @param extra - Optional extra info to append to name (e.g., server name for MCP)
+ * @returns Formatted string with header and content
+ */
+export function formatAttachmentText(
+ label: string,
+ name: string,
+ content: string,
+ extra?: string
+): string {
+ const header = extra ? `${name} (${extra})` : name;
+ return `\n\n--- ${label}: ${header} ---\n${content}`;
+}
export { validateApiKey } from './api-key-validation';
// Attachment utilities
-export {
- getAttachmentDisplayItems,
- type AttachmentDisplayItemsOptions
-} from './attachment-display';
+export { getAttachmentDisplayItems } from './attachment-display';
export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
// Textarea utilities
copyCodeToClipboard,
formatMessageForClipboard,
parseClipboardContent,
- hasClipboardAttachments,
- type ClipboardTextAttachment,
- type ParsedClipboardContent
+ hasClipboardAttachments
} from './clipboard';
// File preview utilities
} from './file-type';
// Formatting utilities
-export { formatFileSize, formatParameters, formatNumber } from './formatters';
+export {
+ formatFileSize,
+ formatParameters,
+ formatNumber,
+ formatJsonPretty,
+ formatTime,
+ formatPerformanceTime,
+ formatAttachmentText
+} from './formatters';
// IME utilities
export { isIMEComposing } from './is-ime-composing';
// Text file utilities
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
+// Debounce utilities
+export { debounce } from './debounce';
+
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
+
+// Data URL utilities
+export { createBase64DataUrl } from './data-url';
+
+// Cache utilities
+export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
+
+// Abort signal utilities
+export {
+ throwIfAborted,
+ isAbortError,
+ createLinkedController,
+ createTimeoutSignal,
+ withAbortSignal
+} from './abort';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+ import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
let { children } = $props();
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
- if (isCtrlOrCmd && event.key === 'k') {
+ if (isCtrlOrCmd && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
}
}
- if (isCtrlOrCmd && event.shiftKey && event.key === 'O') {
+ if (isCtrlOrCmd && event.shiftKey && event.key === KeyboardKey.O_UPPER) {
event.preventDefault();
goto('?new_chat=true#/');
}
- if (event.shiftKey && isCtrlOrCmd && event.key === 'E') {
+ if (event.shiftKey && isCtrlOrCmd && event.key === KeyboardKey.E_UPPER) {
event.preventDefault();
if (chatSidebar?.editActiveConversation) {