]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
Pre-MCP UI and architecture cleanup (#19689)
authorAleksander Grygier <redacted>
Wed, 18 Feb 2026 11:02:02 +0000 (12:02 +0100)
committerGitHub <redacted>
Wed, 18 Feb 2026 11:02:02 +0000 (12:02 +0100)
53 files changed:
tools/server/public/index.html.gz
tools/server/webui/eslint.config.js
tools/server/webui/package-lock.json
tools/server/webui/package.json
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
tools/server/webui/src/lib/components/app/chat/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
tools/server/webui/src/lib/constants/agentic.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/attachment-labels.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/cache.ts
tools/server/webui/src/lib/constants/default-context.ts [deleted file]
tools/server/webui/src/lib/constants/input-classes.ts [deleted file]
tools/server/webui/src/lib/constants/settings-config.ts
tools/server/webui/src/lib/constants/settings-keys.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/files.ts
tools/server/webui/src/lib/enums/index.ts
tools/server/webui/src/lib/enums/ui.ts
tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts [deleted file]
tools/server/webui/src/lib/services/chat.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/chat.ts [deleted file]
tools/server/webui/src/lib/services/index.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/conversations.svelte.ts
tools/server/webui/src/lib/stores/models.svelte.ts
tools/server/webui/src/lib/types/chat.d.ts
tools/server/webui/src/lib/types/common.d.ts
tools/server/webui/src/lib/types/database.d.ts
tools/server/webui/src/lib/types/index.ts
tools/server/webui/src/lib/types/settings.d.ts
tools/server/webui/src/lib/utils/clipboard.ts
tools/server/webui/src/lib/utils/convert-files-to-extra.ts
tools/server/webui/src/lib/utils/formatters.ts
tools/server/webui/src/lib/utils/index.ts
tools/server/webui/src/routes/+layout.svelte

index 05dfd9f17ac38b8db895851d6b2d57985ba6ce12..060c173d9378cdff3fdf3979e35a10c6c29a8a26 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 5baea57f336780d91f77f4937423b427441dfb97..cd20fb383a480eebf47dd6b4034673cab7169f78 100644 (file)
@@ -27,7 +27,9 @@ export default ts.config(
                        // 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'
                }
        },
        {
index 68344168247c70f9f9c04fa230b84c91778aeb0f..8d13e5a535f10385d2fdb068c00eaac271734d8b 100644 (file)
                                "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",
@@ -60,7 +61,7 @@
                                "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",
index a361ce76e385cd501a4ee3b550b1cea8de9b8523..0b74e301b1d8f4888befd7be749a7e081d03af50 100644 (file)
                "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",
@@ -61,7 +62,7 @@
                "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",
index 0b0bf52ad98be7adc409cc755aa16cde1f40acc2..f05bdd8a03a19ed1377d449bed7bbc29fb4b71cd 100644 (file)
@@ -8,7 +8,8 @@
                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>
index a1f5af54e8c379bca066322b92f2e3e0f2533370..6248d84fb0d3706ff8957157a2945b65f4315566 100644 (file)
@@ -1,8 +1,12 @@
 <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">
index e335f6c546d5aa51cab34dbf1adea1c025c6fd9a..3551b0b3d60678e048efface3ca1f644fee5b02d 100644 (file)
@@ -1,20 +1,19 @@
 <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} />
index f8c1b23b06171add428e78fa7e9bb357892c7982..b1cff67dcb532a3a7e4376cb467af095e5d88af7 100644 (file)
@@ -1,6 +1,6 @@
 <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>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
deleted file mode 100644 (file)
index 3545b4a..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-<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>
index cf5aca42a1ff89ee0ef51abc558dfe7217fdfd95..54b11c86249c0d682deb40c66787a9bab62bc4a3 100644 (file)
@@ -13,8 +13,7 @@
        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>
 
index 25895c83b7f35b5063d0d9984431fe7b7a196076..ebf7f433d1b46683694abe05e7db205454cedc8a 100644 (file)
@@ -1,61 +1,35 @@
 <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}
index dbd9b9822852e0b5eca9fc24e018d39bd72bff0a..97b34e92cc97e9926a629fd32e3c3cc3e553e232 100644 (file)
@@ -1,14 +1,15 @@
 <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>
 
index 867def5fc3c85c452fe0a0fe8d7c6de72739c4bc..263f90ec8046c208671a379b5e0b2f6c0a2a8d4d 100644 (file)
@@ -1,26 +1,29 @@
 <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>
index c216ea690b1f98e20bbf6b24dd288129b9f7acc8..299bdc78fc23de15033fe59803074363a3f076c1 100644 (file)
@@ -1,79 +1,26 @@
 <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)}
 />
index b53e82aaf9c40ba232c6073fac4974245d097e70..77951e9d2a35267c30f86a34d9c0ab065b2cc813 100644 (file)
@@ -3,19 +3,18 @@
        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}
index 887df5b7716a58c1e928eab98ca25f683561e3c6..aec2d90c0286135a0cfbdb1a4d0bea4f0424fd63 100644 (file)
@@ -3,15 +3,16 @@
        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}
index 041c6bd251372cfca1134abff534edd31bfaeab2..05a02e27281069357378053eb87ea611a773fdbd 100644 (file)
@@ -1,67 +1,48 @@
 <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}
index c203f10098d8f880c8187113ba9ba9da7491ba15..23143c955cae1d83d6515d22c405799b93fe5b4d 100644 (file)
@@ -1,9 +1,11 @@
 <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>
index 3d432e26bc774e50243cb63d4e4a8250c3d75ff5..ceecf03e54dca59606c870cabe05ac1b9ed65d60 100644 (file)
@@ -1,7 +1,7 @@
 <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>
index 6a0c91346f2090942d24aed6d1e5033f1127b5c1..4d22c83993b5d48ad25df5c27632e0506fd345c8 100644 (file)
@@ -1,5 +1,7 @@
 <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} />
index 16940c16f50f8cb33bcc749ba7bca075dd995b6c..c3cb8343fc23109058de3b62fbd09594b4b7c444 100644 (file)
@@ -5,8 +5,6 @@
                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">
index a6f51f47d6e7c955de9ac4f08ebcfa6bd79bde36..07749944118779d218dbf14b281ca8f158f6044a 100644 (file)
@@ -6,6 +6,8 @@
        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';
@@ -31,7 +33,7 @@
 
 {#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}
diff --git a/tools/server/webui/src/lib/components/app/chat/index.ts b/tools/server/webui/src/lib/components/app/chat/index.ts
new file mode 100644 (file)
index 0000000..8c0622f
--- /dev/null
@@ -0,0 +1,597 @@
+/**
+ *
+ * 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';
diff --git a/tools/server/webui/src/lib/components/app/dialogs/index.ts b/tools/server/webui/src/lib/components/app/dialogs/index.ts
new file mode 100644 (file)
index 0000000..f34af73
--- /dev/null
@@ -0,0 +1,416 @@
+/**
+ *
+ * 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';
index 142622ef0a27d25fb87f9d0293bb53fb657545e8..3e3df48fd805e13d309b0bff9c8e61ca617e6d83 100644 (file)
@@ -1,68 +1,10 @@
 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';
index ec27293078e8592a23ed3f8a451758565ed0a758..f6b16408ca160f77877ac74140d15e7ca9886f11 100644 (file)
@@ -31,8 +31,6 @@
                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 {
@@ -41,9 +39,7 @@
                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());
diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts
new file mode 100644 (file)
index 0000000..4ae86bb
--- /dev/null
@@ -0,0 +1,37 @@
+// 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;
diff --git a/tools/server/webui/src/lib/constants/attachment-labels.ts b/tools/server/webui/src/lib/constants/attachment-labels.ts
new file mode 100644 (file)
index 0000000..e03da4e
--- /dev/null
@@ -0,0 +1,2 @@
+export const ATTACHMENT_LABEL_FILE = 'File';
+export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
index acdb7a6430951f58447e55020318c79d10097ee4..dbd5dcbddebf3b9f30f585c79a4b14c2900fa72b 100644 (file)
@@ -3,31 +3,40 @@
  */
 
 /**
- * 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;
diff --git a/tools/server/webui/src/lib/constants/default-context.ts b/tools/server/webui/src/lib/constants/default-context.ts
deleted file mode 100644 (file)
index 78f3111..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export const DEFAULT_CONTEXT = 4096;
diff --git a/tools/server/webui/src/lib/constants/input-classes.ts b/tools/server/webui/src/lib/constants/input-classes.ts
deleted file mode 100644 (file)
index 2781fdb..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export { INPUT_CLASSES } from './css-classes';
index 1b959f3b69f217371562731ae0ed1d8725bd1075..6f6dbea2ec107039527959249f2a3750b4f2bb9b 100644 (file)
@@ -1,12 +1,14 @@
+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,
@@ -91,8 +93,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
        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:
@@ -118,3 +118,9 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
        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 }
+];
diff --git a/tools/server/webui/src/lib/constants/settings-keys.ts b/tools/server/webui/src/lib/constants/settings-keys.ts
new file mode 100644 (file)
index 0000000..63960d4
--- /dev/null
@@ -0,0 +1,52 @@
+/**
+ * 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;
index a4f079d405fda1defbd438901af9c08843ad9a76..839720dd097429d8ee3a1b226b2f0bf14be90f13 100644 (file)
@@ -136,9 +136,28 @@ export enum FileExtensionText {
        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 {
@@ -152,6 +171,7 @@ export enum MimeTypeAudio {
 
 export enum MimeTypeImage {
        JPEG = 'image/jpeg',
+       JPG = 'image/jpg',
        PNG = 'image/png',
        GIF = 'image/gif',
        WEBP = 'image/webp',
index 5b39eebbb15fb37cf88eb044cea2a6eec68621c2..8683f3c994ff3dfa3848fd610d57478907ca0824 100644 (file)
@@ -2,11 +2,11 @@ export { AttachmentType } from './attachment';
 
 export {
        ChatMessageStatsView,
-       ReasoningFormat,
+       ContentPartType,
+       ErrorDialogType,
        MessageRole,
        MessageType,
-       ContentPartType,
-       ErrorDialogType
+       ReasoningFormat
 } from './chat';
 
 export {
@@ -19,6 +19,9 @@ export {
        FileExtensionAudio,
        FileExtensionPdf,
        FileExtensionText,
+       MimeTypePrefix,
+       MimeTypeIncludes,
+       UriPattern,
        MimeTypeApplication,
        MimeTypeAudio,
        MimeTypeImage,
@@ -31,6 +34,6 @@ export { ServerRole, ServerModelStatus } from './server';
 
 export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
 
-export { KeyboardKey } from './keyboard';
+export { ColorMode, UrlPrefix } from './ui';
 
-export { UrlPrefix } from './ui';
+export { KeyboardKey } from './keyboard';
index 72a58482636c6e8a68966fddd497f2a2e44ae24e..116fe911b032c1cf184295868685568e08e1cded 100644 (file)
@@ -1,5 +1,11 @@
+export enum ColorMode {
+       LIGHT = 'light',
+       DARK = 'dark',
+       SYSTEM = 'system'
+}
+
 /**
- * URL prefixes for protocol detection.
+ * URL prefixes for protocol detection
  */
 export enum UrlPrefix {
        DATA = 'data:',
diff --git a/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts b/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
deleted file mode 100644 (file)
index f52d8dc..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-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
-       };
-}
diff --git a/tools/server/webui/src/lib/services/chat.service.ts b/tools/server/webui/src/lib/services/chat.service.ts
new file mode 100644 (file)
index 0000000..7184494
--- /dev/null
@@ -0,0 +1,884 @@
+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);
+       }
+}
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
deleted file mode 100644 (file)
index 55af0ce..0000000
+++ /dev/null
@@ -1,784 +0,0 @@
-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);
-       }
-}
index b59d7cec34dd85b5b68fc0fa99f41c4337b949da..b215bf5c543ea56b680df2d20542ae5e63253fb3 100644 (file)
@@ -1,5 +1,214 @@
-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';
index 362e6d44b319a678cf1a790cc681712c8dbcfa0a..5dfec293ab37e5f843ae83c59cd2d5265495e067 100644 (file)
@@ -1,3 +1,17 @@
+/**
+ * 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';
@@ -11,618 +25,418 @@ import {
        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) {
@@ -631,18 +445,16 @@ class ChatStore {
                }
        }
 
-       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
@@ -651,174 +463,10 @@ class ChatStore {
                );
        }
 
-       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) {
@@ -827,137 +475,280 @@ class ChatStore {
                }
                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 = {
@@ -971,16 +762,11 @@ class ChatStore {
                                                                : 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);
                        }
                }
@@ -989,45 +775,30 @@ class ChatStore {
        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),
@@ -1040,44 +811,84 @@ class ChatStore {
                                }
                        );
                } 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);
                }
        }
@@ -1095,17 +906,17 @@ class ChatStore {
                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');
                                }
@@ -1120,15 +931,17 @@ class ChatStore {
                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 };
        }
 
@@ -1138,6 +951,7 @@ class ChatStore {
                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);
@@ -1152,6 +966,7 @@ class ChatStore {
                                        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(
@@ -1159,6 +974,7 @@ class ChatStore {
                                        );
                                }
                        }
+
                        await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
                        await conversationsStore.refreshActiveMessages();
 
@@ -1168,27 +984,17 @@ class ChatStore {
                }
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // 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);
 
@@ -1197,22 +1003,51 @@ class ChatStore {
 
                        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);
 
@@ -1220,23 +1055,8 @@ class ChatStore {
                                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
@@ -1254,74 +1074,78 @@ class ChatStore {
                                                        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);
                }
        }
@@ -1332,10 +1156,11 @@ class ChatStore {
                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 {
@@ -1347,22 +1172,22 @@ class ChatStore {
                                                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);
@@ -1377,22 +1202,17 @@ class ChatStore {
                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);
@@ -1401,10 +1221,10 @@ class ChatStore {
                        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);
@@ -1417,35 +1237,24 @@ class ChatStore {
                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,
@@ -1453,7 +1262,6 @@ class ChatStore {
                                        timestamp: Date.now(),
                                        role: msg.role,
                                        content: newContent,
-                                       thinking: msg.thinking || '',
                                        toolCalls: msg.toolCalls || '',
                                        children: [],
                                        extra: extrasToUse,
@@ -1463,86 +1271,23 @@ class ChatStore {
                        );
                        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);
 
@@ -1556,18 +1301,19 @@ class ChatStore {
                        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);
@@ -1575,117 +1321,194 @@ class ChatStore {
                }
        }
 
-       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;
@@ -1695,7 +1518,6 @@ class ChatStore {
 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();
@@ -1706,9 +1528,4 @@ export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(c
 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);
index 1d1c6f16a1ca93477891b66bf4793b87384eb5f4..9d71b67a80a42d50a7604f81d476b29a07deb0b9 100644 (file)
@@ -1,54 +1,38 @@
-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[]>([]);
@@ -65,102 +49,110 @@ class ConversationsStore {
        /** 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
@@ -170,7 +162,7 @@ class ConversationsStore {
                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 = [];
 
@@ -196,13 +188,15 @@ class ConversationsStore {
 
                        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;
@@ -213,21 +207,65 @@ class ConversationsStore {
        }
 
        /**
-        * 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;
@@ -241,18 +279,32 @@ class ConversationsStore {
 
                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
         */
@@ -264,10 +316,11 @@ class ConversationsStore {
 
                        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);
@@ -278,22 +331,23 @@ class ConversationsStore {
         * 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;
                        }
 
@@ -305,21 +359,6 @@ class ConversationsStore {
                }
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // 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
         */
@@ -331,35 +370,51 @@ class ConversationsStore {
                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 (
@@ -371,61 +426,22 @@ class ConversationsStore {
                        ) {
                                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> {
@@ -456,7 +472,7 @@ class ConversationsStore {
                }
 
                const allData = await Promise.all(
-                       allConversations.map(async (conv: DatabaseConversation) => {
+                       allConversations.map(async (conv) => {
                                const messages = await DatabaseService.getConversationMessages(conv.id);
                                return { conv, messages };
                        })
@@ -536,15 +552,6 @@ class ConversationsStore {
                });
        }
 
-       /**
-        * 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
@@ -558,61 +565,8 @@ class ConversationsStore {
                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 =
@@ -641,26 +595,16 @@ class ConversationsStore {
                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;
index 0a35cd44ac76a740975f0d18092949cd6c211b78..4cb61672203075e45fef3761b5dfca4d2d295bc0 100644 (file)
@@ -1,8 +1,9 @@
 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
@@ -32,9 +33,13 @@ import { serverStore } from '$lib/stores/server.svelte';
  * - **Lazy loading**: ensureModelLoaded() loads models on demand
  */
 class ModelsStore {
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // State
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * State
+        *
+        *
+        */
 
        models = $state<ModelOption[]>([]);
        routerModels = $state<ApiModelDataEntry[]>([]);
@@ -48,10 +53,14 @@ class ModelsStore {
        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());
 
        /**
@@ -59,9 +68,13 @@ class ModelsStore {
         */
        propsCacheVersion = $state(0);
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // Computed Getters
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * Computed Getters
+        *
+        *
+        */
 
        get selectedModel(): ModelOption | null {
                if (!this.selectedModelId) return null;
@@ -95,22 +108,24 @@ class ModelsStore {
                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 {
@@ -155,15 +170,17 @@ class ModelsStore {
         * 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;
        }
 
        /**
@@ -181,9 +198,13 @@ class ModelsStore {
                return this.modelPropsFetching.has(modelId);
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // Status Queries
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * Status Queries
+        *
+        *
+        */
 
        isModelLoaded(modelId: string): boolean {
                const model = this.routerModels.find((m) => m.id === modelId);
@@ -208,9 +229,13 @@ class ModelsStore {
                return usage !== undefined && usage.size > 0;
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // Data Fetching
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * Data Fetching
+        *
+        *
+        */
 
        /**
         * Fetch list of models from server and detect server role
@@ -224,7 +249,6 @@ class ModelsStore {
                this.error = null;
 
                try {
-                       // Ensure server props are loaded (for role detection and MODEL mode modalities)
                        if (!serverStore.props) {
                                await serverStore.fetch();
                        }
@@ -251,7 +275,6 @@ class ModelsStore {
 
                        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;
@@ -260,9 +283,7 @@ class ModelsStore {
                                        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
                                );
@@ -302,7 +323,6 @@ class ModelsStore {
         * @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;
 
@@ -310,7 +330,6 @@ class ModelsStore {
                        return null;
                }
 
-               // Avoid duplicate fetches
                if (this.modelPropsFetching.has(modelId)) return null;
 
                this.modelPropsFetching.add(modelId);
@@ -335,7 +354,6 @@ class ModelsStore {
                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 {
@@ -357,7 +375,6 @@ class ModelsStore {
                                return { ...model, modalities };
                        });
 
-                       // Increment version to trigger reactivity
                        this.propsCacheVersion++;
                } catch (error) {
                        console.warn('Failed to fetch modalities for loaded models:', error);
@@ -382,16 +399,19 @@ class ModelsStore {
                                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
@@ -443,9 +463,13 @@ class ModelsStore {
                return this.models.some((model) => model.model === modelName);
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // Loading/Unloading Models
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * Loading/Unloading Models
+        *
+        *
+        */
 
        /**
         * WORKAROUND: Polling for model status after load/unload operations.
@@ -486,7 +510,6 @@ class ModelsStore {
                                return;
                        }
 
-                       // Wait before next poll
                        await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
                }
 
@@ -511,8 +534,6 @@ class ModelsStore {
 
                try {
                        await ModelsService.load(modelId);
-
-                       // Poll until model is loaded
                        await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
 
                        await this.updateModelModalities(modelId);
@@ -562,9 +583,13 @@ class ModelsStore {
                await this.loadModel(modelId);
        }
 
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
-       // Utilities
-       // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
+       /**
+        *
+        *
+        * Utilities
+        *
+        *
+        */
 
        private toDisplayName(id: string): string {
                const segments = id.split(/\\|\//);
@@ -586,6 +611,14 @@ class ModelsStore {
                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();
index 8d4661960a60377cb93f39720c85904eb60a735f..afcaf3856f02670c205e8e1d0cf247dfb7073583 100644 (file)
@@ -1,8 +1,5 @@
 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;
@@ -61,6 +58,9 @@ export interface ChatMessageTimings {
        prompt_n?: number;
 }
 
+/**
+ * Callbacks for streaming chat responses
+ */
 export interface ChatStreamCallbacks {
        onChunk?: (chunk: string) => void;
        onReasoningChunk?: (chunk: string) => void;
@@ -77,12 +77,18 @@ export interface ChatStreamCallbacks {
        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;
@@ -91,17 +97,26 @@ export interface LiveProcessingStats {
        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[];
index a4ae12fb86921815dfed5e807d37c208e68d41a1..a8d9d360c45c1de32bf2fe74531923783e5afac0 100644 (file)
@@ -1,7 +1,12 @@
 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;
@@ -9,16 +14,19 @@ export interface KeyValuePair {
 }
 
 /**
- * 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;
@@ -33,3 +41,5 @@ export interface ParsedClipboardContent {
        message: string;
        textAttachments: ClipboardTextAttachment[];
 }
+
+export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
index 1a336e059cfca1b0e226f88cc50341df75f0c603..e912641b1d8f41cf1f57639062102bce605a92b3 100644 (file)
@@ -35,9 +35,9 @@ export interface DatabaseMessageExtraPdfFile {
        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 {
@@ -60,26 +60,24 @@ export interface DatabaseMessage {
        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[];
index 7b1bba717d7c70c935503ba4b5953a4ff0f78b91..bb3affd17e54ce25b12c05730c1947d45f1efa6b 100644 (file)
@@ -34,8 +34,6 @@ export type {
 
 // Chat types
 export type {
-       ChatMessageType,
-       ChatRole,
        ChatUploadedFile,
        ChatAttachmentDisplayItem,
        ChatAttachmentPreviewItem,
@@ -48,7 +46,7 @@ export type {
        LiveGenerationStats,
        AttachmentDisplayItemsOptions,
        FileProcessingResult
-} from './chat';
+} from './chat.d';
 
 // Database types
 export type {
index eca6d8c4dab6b75a98cd7a9130ff84569e7d7ae4..303462b2ccb6c91823a3572fcd940e5fd4a471ec 100644 (file)
@@ -1,7 +1,7 @@
 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;
 
@@ -69,14 +69,18 @@ export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
        [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 {
index 940e64c8ff0b62be017c746442493c94154badd0..7ea1fa33bea9aa33f743c9392550bc79a93d7296 100644 (file)
@@ -3,8 +3,10 @@ import { AttachmentType } from '$lib/enums';
 import type {
        DatabaseMessageExtra,
        DatabaseMessageExtraTextFile,
-       DatabaseMessageExtraLegacyContext
-} from '$lib/types/database';
+       DatabaseMessageExtraLegacyContext,
+       ClipboardTextAttachment,
+       ParsedClipboardContent
+} from '$lib/types';
 
 /**
  * Copy text to clipboard with toast notification
@@ -68,23 +70,6 @@ export async function copyCodeToClipboard(
        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.
  *
index 6eb50f6dce423549ef4ce5dc08be60a5207b1235..11d65a44012e65b254d59a6952cf539ca45a6da9 100644 (file)
@@ -7,6 +7,7 @@ import { modelsStore } from '$lib/stores/models.svelte';
 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) => {
@@ -25,11 +26,6 @@ function readFileAsBase64(file: File): Promise<string> {
        });
 }
 
-export interface FileProcessingResult {
-       extras: DatabaseMessageExtra[];
-       emptyFiles: string[];
-}
-
 export async function parseFilesToMessageExtras(
        files: ChatUploadedFile[],
        activeModelId?: string
index bdf2ca26fd552840de3db12022b4d595b1fba206..37a8a3358cf98b362dc13c90d2018d5877387258 100644 (file)
@@ -1,3 +1,11 @@
+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
@@ -93,19 +101,19 @@ export function formatTime(date: Date): string {
 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[] = [];
 
@@ -123,3 +131,23 @@ export function formatPerformanceTime(ms: number): 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}`;
+}
index 5eb2bbaea1e9e24e21311d91b64f7cca1ba6bebd..7aa4ab9756c9ccba44beaa21ef1ab891db59ad01 100644 (file)
@@ -13,10 +13,7 @@ export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './a
 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
@@ -46,9 +43,7 @@ export {
        copyCodeToClipboard,
        formatMessageForClipboard,
        parseClipboardContent,
-       hasClipboardAttachments,
-       type ClipboardTextAttachment,
-       type ParsedClipboardContent
+       hasClipboardAttachments
 } from './clipboard';
 
 // File preview utilities
@@ -64,7 +59,15 @@ export {
 } 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';
@@ -94,5 +97,23 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
 // 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';
index 095827b9ca5e10ac9480c053f2a141f6230c8e22..705066119dcfb1d7038a19cc328c96c0acc6f8c3 100644 (file)
@@ -15,6 +15,7 @@
        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();
@@ -43,7 +44,7 @@
        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) {