feat: add SettingsPage with HuggingFace model search and fix flexbox layout

- Add SettingsPage component with macOS/Linear-style settings UI (sp-* classes)
- Fix HuggingFace results list squeeze: sp-card-row--full now has align-items: stretch
  overriding the inherited flex-start, ensuring children fill 100% horizontal width
- Fix sp-hf-file row layout: width 100%, grid columns for name/quant/size/download
- Fix sp-hf-name truncation: explicit overflow/ellipsis/white-space in CSS
- Add white-space: nowrap to sp-hf-size and sp-hf-quant-badge to protect secondary columns
- Add runtime-deps crate, Tailwind/PostCSS config, runtime-defaults, MessageBubble test
- Update Cargo workspace, inference-server, tauri runtime types and process handling
This commit is contained in:
Evandro Fonseca Junior committed 2026-03-30 23:35:08 -03:00
1 parent 9da25c3688
commit 01b629cb4a
41 files changed
+6864 -822

No files matched your search

+939
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -11,10 +11,12 @@
"tauri": "tauri"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -25,7 +27,10 @@
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.27",
"jsdom": "^29.0.1",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.3",
"vite": "^5.4.8",
"vitest": "^4.1.2"
+590
View File
@@ -45,15 +45,32 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0([email protected])
jsdom:
specifier: ^29.0.1
version: 29.0.1
typescript:
specifier: ^5.5.3
version: 5.9.3
vite:
specifier: ^5.4.8
version: 5.4.21
vitest:
specifier: ^4.1.2
version: 4.1.2([email protected])([email protected])
packages:
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/[email protected]':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -137,6 +154,46 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bramus/[email protected]':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/[email protected]':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/[email protected]':
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/[email protected]':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@esbuild/[email protected]':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -275,6 +332,15 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/[email protected]':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -432,6 +498,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -526,6 +595,12 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/[email protected]':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
@@ -553,11 +628,47 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/[email protected]':
resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
'@vitest/[email protected]':
resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
'@vitest/[email protected]':
resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
'@vitest/[email protected]':
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
[email protected]:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
engines: {node: '>=6.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
[email protected]:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -566,12 +677,24 @@ packages:
[email protected]:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
[email protected]:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
[email protected]:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -581,12 +704,22 @@ packages:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
[email protected]:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
[email protected]:
resolution: {integrity: sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==}
[email protected]:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
[email protected]:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -596,6 +729,22 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
[email protected]:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -609,9 +758,25 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected]:
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
[email protected]:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -626,14 +791,24 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
engines: {node: '>= 18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -645,13 +820,30 @@ packages:
[email protected]:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
[email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
[email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -665,11 +857,19 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
[email protected]:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -677,15 +877,61 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
[email protected]:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
[email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
[email protected]:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
[email protected]:
resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
engines: {node: '>=20.18.1'}
[email protected]:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -723,11 +969,92 @@ packages:
terser:
optional: true
[email protected]:
resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.2
'@vitest/browser-preview': 4.1.2
'@vitest/browser-webdriverio': 4.1.2
'@vitest/ui': 4.1.2
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
[email protected]:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
snapshots:
'@asamuzakjp/[email protected]':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-color-parser': 4.0.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.2.7
'@asamuzakjp/[email protected]':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
'@asamuzakjp/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -840,6 +1167,34 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bramus/[email protected]':
dependencies:
css-tree: 3.2.1
'@csstools/[email protected]': {}
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.1.1(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected](@csstools/[email protected])':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/[email protected]([email protected])':
optionalDependencies:
css-tree: 3.2.1
'@csstools/[email protected]': {}
'@esbuild/[email protected]':
optional: true
@@ -909,6 +1264,8 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@exodus/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1005,6 +1362,8 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@standard-schema/[email protected]': {}
'@tauri-apps/[email protected]': {}
'@tauri-apps/[email protected]':
@@ -1079,6 +1438,13 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
dompurify: 3.3.3
@@ -1111,8 +1477,55 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/[email protected]':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.2
'@vitest/utils': 4.1.2
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/[email protected]([email protected])':
dependencies:
'@vitest/spy': 4.1.2
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21
'@vitest/[email protected]':
dependencies:
tinyrainbow: 3.1.0
'@vitest/[email protected]':
dependencies:
'@vitest/utils': 4.1.2
pathe: 2.0.3
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 4.1.2
'@vitest/utils': 4.1.2
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/[email protected]': {}
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 4.1.2
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
require-from-string: 2.0.2
[email protected]:
dependencies:
baseline-browser-mapping: 2.10.11
@@ -1123,20 +1536,40 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected]:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]:
optionalDependencies:
'@types/trusted-types': 2.0.7
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -1165,6 +1598,16 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.4
[email protected]:
optional: true
@@ -1172,8 +1615,42 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@asamuzakjp/css-color': 5.1.1
'@asamuzakjp/dom-selector': 7.0.4
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.2([email protected])
'@exodus/bytes': 1.15.0
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.24.6
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
[email protected]: {}
[email protected]: {}
@@ -1182,26 +1659,46 @@ snapshots:
dependencies:
js-tokens: 4.0.0
[email protected]: {}
[email protected]:
dependencies:
yallist: 3.1.1
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
entities: 6.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]([email protected]):
dependencies:
loose-envify: 1.4.0
@@ -1214,6 +1711,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
@@ -1245,16 +1744,55 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.0
fsevents: 2.3.3
[email protected]:
dependencies:
xmlchars: 2.2.0
[email protected]:
dependencies:
loose-envify: 1.4.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.4
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
tldts-core: 7.0.27
[email protected]:
dependencies:
tldts: 7.0.27
[email protected]:
dependencies:
punycode: 2.3.1
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
browserslist: 4.28.1
@@ -1269,4 +1807,56 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
[email protected]([email protected])([email protected]):
dependencies:
'@vitest/expect': 4.1.2
'@vitest/mocker': 4.1.2([email protected])
'@vitest/pretty-format': 4.1.2
'@vitest/runner': 4.1.2
'@vitest/snapshot': 4.1.2
'@vitest/spy': 4.1.2
'@vitest/utils': 4.1.2
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 5.4.21
why-is-node-running: 2.3.0
optionalDependencies:
jsdom: 29.0.1
transitivePeerDependencies:
- msw
[email protected]:
dependencies:
xml-name-validator: 5.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@exodus/bytes': 1.15.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected]:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+197 -8
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -521,6 +530,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -565,13 +585,34 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -582,7 +623,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -830,6 +871,7 @@ name = "forgelm-chat"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"runtime-deps",
"serde",
"serde_json",
"tauri",
@@ -1380,7 +1422,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
@@ -2787,6 +2829,17 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -2882,7 +2935,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
@@ -2957,6 +3010,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "runtime-deps"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
"serde",
"serde_json",
"sha2",
"ureq",
"zip",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -2978,6 +3044,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3655,7 +3722,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3705,7 +3772,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4268,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@@ -4366,6 +4433,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4678,6 +4763,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -4917,6 +5011,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4968,6 +5071,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5025,6 +5143,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5043,6 +5167,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5061,6 +5191,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5091,6 +5227,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5109,6 +5251,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5127,6 +5275,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5145,6 +5299,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5295,7 +5455,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dom_query",
"dpi",
"dunce",
@@ -5453,8 +5613,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+1
View File
@@ -25,6 +25,7 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime-deps = { path = "../../crates/runtime-deps" }
[profile.release]
panic = "abort"
+1 -1
View File
@@ -118,7 +118,7 @@ fn start_runtime_inner(
inner.config.clone()
};
let child = match spawn_localllm(&config) {
let child = match spawn_localllm(app, &config) {
Ok(child) => child,
Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
+36 -3
View File
@@ -1,7 +1,13 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig;
use runtime_deps::{
apply_runtime_to_command, ensure_llama_runtime, recommended_variant_override_hint,
EnsureRuntimeRequest,
};
pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") {
@@ -52,7 +58,17 @@ pub fn find_localllm_binary() -> Option<PathBuf> {
None
}
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
fn runtime_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
std::fs::create_dir_all(&app_data_dir)
.map_err(|error| format!("Cannot create app data dir: {error}"))?;
Ok(app_data_dir.join("runtime-cache"))
}
pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child, String> {
let model_path = config
.model_path
.as_deref()
@@ -65,11 +81,28 @@ pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
.to_string()
})?;
Command::new(&binary_path)
let runtime = ensure_llama_runtime(&EnsureRuntimeRequest {
cache_root: &runtime_cache_root(app)?,
gpu_layers: config.gpu_layers,
variant_override: None,
binary_path: Some(&binary_path),
})
.map_err(|error| {
format!(
"Failed to prepare llama runtime dependencies: {error}\n{}",
recommended_variant_override_hint()
)
})?;
let mut command = Command::new(&binary_path);
command
.env("LOCALLLM_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string());
apply_runtime_to_command(&mut command, &runtime);
command
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
}
+11 -2
View File
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::model_library::types::RemotePreset;
pub const DEFAULT_CTX_SIZE: u32 = 4096;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionMode {
@@ -102,7 +104,7 @@ impl Default for RuntimeConfig {
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
ctx_size: 4096,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: Vec::new(),
selected_model_id: None,
remote_presets: Vec::new(),
@@ -113,7 +115,14 @@ impl Default for RuntimeConfig {
#[cfg(test)]
mod tests {
use super::{ConnectionMode, RuntimeConfig};
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
#[test]
fn runtime_config_defaults_to_safe_context_window() {
let config = RuntimeConfig::default();
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
}
#[test]
fn runtime_config_deserializes_old_shape_with_new_defaults() {
+34 -26
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import Sidebar from "./components/Sidebar";
import ChatView from "./components/ChatView";
import SettingsPage from "./features/settings/SettingsPage";
import { useDB } from "./hooks/useDB";
import { useModels } from "./hooks/useModels";
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
@@ -13,6 +14,7 @@ export default function App() {
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [view, setView] = useState<"chat" | "settings">("chat");
const runtime = useRuntimeManager();
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
@@ -65,22 +67,24 @@ export default function App() {
}
return (
<div className={`app-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
runtime={runtime}
onCloseMobile={() => setSidebarOpen(false)}
/>
{sidebarOpen && (
<div className={`app-layout${sidebarOpen && view !== "settings" ? " sidebar-open" : ""}`}>
{view !== "settings" && (
<Sidebar
chats={chats}
currentChatId={currentChatId}
onSelectChat={handleSelectChat}
onNewChat={newChat}
onDeleteChat={deleteChat}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
onOpenSettings={() => setView("settings")}
onCloseMobile={() => setSidebarOpen(false)}
/>
)}
{view !== "settings" && sidebarOpen && (
<button
type="button"
className="app-sidebar-backdrop"
@@ -89,16 +93,20 @@ export default function App() {
/>
)}
<div className="app-main">
<ChatView
db={db!}
chatId={currentChatId}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
/>
{view === "settings" ? (
<SettingsPage runtime={runtime} onClose={() => setView("chat")} />
) : (
<ChatView
db={db!}
chatId={currentChatId}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
/>
)}
</div>
</div>
);
File diff suppressed because it is too large. Load diff
+395 -194
View File
@@ -5,6 +5,7 @@ import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
import "./ChatView.css";
const MAX_MEMORY = 20;
@@ -31,37 +32,55 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const [tps, setTps] = useState<number | null>(null);
const [useStream, setUseStream] = useState(true);
const [useMemory, setUseMemory] = useState(true);
const [streamTokenCount, setStreamTokenCount] = useState(0);
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const [showMemoryModal, setShowMemoryModal] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const activeChatIdRef = useRef<number | null>(chatId);
const pendingRequestChatIdRef = useRef<number | null>(null);
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
const messageQueueRef = useRef<string[]>([]);
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
// Single helper to keep ref and state in sync — use at every queue mutation site
const setQueue = (next: string[]) => {
messageQueueRef.current = next;
setMessageQueue(next);
};
const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts();
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
totals.prompt_tokens += message.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0;
return totals;
const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
acc.prompt_tokens += msg.prompt_tokens ?? 0;
acc.completion_tokens += msg.completion_tokens ?? 0;
acc.total_tokens += msg.total_tokens ?? 0;
return acc;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
const memoryMessageCount = chatId != null
? Math.min(
messages.filter(m => m.content && !m.content.startsWith("⚠️") && m.content !== "internal error").length,
useMemory ? MAX_MEMORY : 1,
)
: 0;
useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
console.log(`[ChatView] loadMessages: chatId=${targetChatId}`);
if (targetChatId == null) { console.trace("[ChatView] loadMessages: no chatId — clearing messages, called from"); setMessages([]); return; }
if (targetChatId == null) { setMessages([]); return; }
const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId]
[targetChatId],
);
console.log(`[ChatView] loadMessages: loaded ${rows.length} messages for chat ${targetChatId}`, rows.map(r => ({ id: r.id, role: r.role, contentLen: r.content?.length ?? 0 })));
console.trace(`[ChatView] loadMessages: setMessages(${rows.length}) called from`);
setMessages(rows);
}, [db, chatId]);
// Keep ref always pointing to latest loadMessages so async closures don't go stale
loadMessagesRef.current = loadMessages;
useEffect(() => { loadMessages(); }, [loadMessages]);
@@ -69,18 +88,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
useEffect(() => {
activeChatIdRef.current = chatId;
const switchedAwayFromPendingChat = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
const switchedAway = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
if (abortRef.current && switchedAwayFromPendingChat) {
if (abortRef.current && switchedAway) {
abortRef.current.abort();
abortRef.current = null;
messageQueueRef.current = [];
setMessageQueue([]);
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
setStreamTokenCount(0);
pendingRequestChatIdRef.current = null;
return;
}
@@ -92,6 +113,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setTps(null);
setStreamTokenCount(0);
}
}, [chatId]);
@@ -109,7 +131,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const getOrCreateChat = useCallback((firstMessage?: string): number => {
if (chatId != null) return chatId;
const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
console.log(`[ChatView] getOrCreateChat: created chat id=${newId}`);
onChatCreated(newId);
return newId;
}, [chatId, db, onChatCreated]);
@@ -127,30 +148,27 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const limit = useMemory ? MAX_MEMORY : 1;
return db.query<{ role: string; content: string }>(
"SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?",
[cid, limit]
[cid, limit],
).reverse();
}, [db, useMemory]);
const send = useCallback(async (text: string, fromChatId?: number) => {
console.log(`[ChatView] send: text="${text.slice(0, 60)}…" fromChatId=${fromChatId} generating=${generating} model=${model} server=${server}`);
if (!text.trim() || generating) { console.warn("[ChatView] send: blocked — empty text or already generating"); return; }
if (getComposerNotice(model, server)) { console.warn("[ChatView] send: blocked — composerNotice active"); return; }
if (!text.trim() || generating) return;
if (getComposerNotice(model, server)) return;
setGenerating(true);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
const cid = fromChatId ?? getOrCreateChat(text);
console.log(`[ChatView] send: using chatId=${cid}`);
activeChatIdRef.current = cid;
pendingRequestChatIdRef.current = cid;
// Save user message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
console.log(`[ChatView] send: inserted user message, chat now has ${count?.c} messages`);
if (count?.c === 1) ensureChatTitle(cid, text);
loadMessagesRef.current(cid);
@@ -162,7 +180,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
abortRef.current = new AbortController();
console.log(`[ChatView] send: fetching ${server}/v1/chat/completions — stream=${useStream}, messages=${apiMessages.length}`);
try {
const res = await fetch(`${server}/v1/chat/completions`, {
method: "POST",
@@ -177,7 +194,6 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
}),
});
console.log(`[ChatView] send: response status=${res.status} ok=${res.ok}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`);
@@ -188,14 +204,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
let tokenCount = 0;
if (useStream) {
console.log("[ChatView] send: starting stream read");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
const decoder = new TextDecoder('utf-8');
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) { console.log(`[ChatView] send: stream done, total tokens received=${tokenCount}`); break; }
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop()!;
@@ -203,12 +218,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") { console.log("[ChatView] send: received [DONE]"); continue; }
if (data === "[DONE]") continue;
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
tokenCount++;
setStreamTokenCount(tokenCount);
fullContent += delta;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
@@ -216,17 +232,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
if (chunk.usage) { usage = chunk.usage; console.log("[ChatView] send: received usage", usage); }
} catch (parseErr) { console.warn("[ChatView] send: failed to parse chunk", parseErr, data); }
if (chunk.usage) usage = chunk.usage;
} catch { /* ignore malformed chunks */ }
}
}
console.log(`[ChatView] send: stream complete — fullContent.length=${fullContent.length}`);
} else {
console.log("[ChatView] send: reading blocking response");
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
console.log(`[ChatView] send: blocking response — fullContent.length=${fullContent.length}, usage=`, usage);
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
@@ -234,13 +247,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
if (parsed.reasoning) setShowLiveReasoning(true);
}
// Save assistant message — only if there is real content to persist
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
if (!finalContent && !parsed.reasoning) {
console.warn("[ChatView] send: assistant returned empty content — not saving to DB");
} else {
console.log(`[ChatView] send: saving assistant message — finalContent.length=${finalContent.length}, reasoning.length=${parsed.reasoning?.length ?? 0}, usage=`, usage);
if (finalContent || parsed.reasoning) {
db.run(
`INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
@@ -259,52 +268,58 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated();
console.log(`[ChatView] send: assistant message saved to DB for chat ${cid}`);
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
console.error(`[ChatView] send: error — ${err.message}`, err);
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
cid,
`⚠️ Error: ${err.message}`,
]);
onChatUpdated();
} else {
console.log("[ChatView] send: aborted");
}
} finally {
console.log(`[ChatView] send: finally — activeChatIdRef=${activeChatIdRef.current} cid=${cid}`);
// Direct DB check before reload
const dbCheck = db.query<{ id: number; role: string; content: string }>(
"SELECT id, role, substr(content,1,40) as content FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[cid]
);
console.log(`[ChatView] send: DB has ${dbCheck.length} messages for chat ${cid}:`, dbCheck);
setGenerating(false);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
setStreamTokenCount(0);
if (activeChatIdRef.current === cid) {
console.log(`[ChatView] send: reloading messages for chat ${cid}`);
loadMessagesRef.current(cid);
} else {
console.warn(`[ChatView] send: skipping reload — active chat changed to ${activeChatIdRef.current}`);
}
pendingRequestChatIdRef.current = null;
abortRef.current = null;
// Process message queue
const nextInQueue = messageQueueRef.current[0];
if (nextInQueue && activeChatIdRef.current === cid) {
setQueue(messageQueueRef.current.slice(1));
setTimeout(() => sendRef.current(nextInQueue), 100);
}
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
const handleSend = () => {
const text = input.trim();
if (!text || composerNotice) return;
setInput("");
if (textareaRef.current) { textareaRef.current.style.height = "auto"; }
if (textareaRef.current) textareaRef.current.style.height = "auto";
if (generating) {
setQueue([...messageQueueRef.current, text]);
return;
}
send(text);
};
const handleStop = () => { abortRef.current?.abort(); };
const handleStop = () => {
abortRef.current?.abort();
setQueue([]);
};
const handleDeleteMessage = (id: number) => {
if (!window.confirm("Delete this message?")) return;
@@ -324,7 +339,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
loadMessages();
send(newContent, msg.chat_id);
if (generating) {
setQueue([...messageQueueRef.current, newContent]);
} else {
send(newContent, msg.chat_id);
}
};
const handleEditAssistant = (id: number, newContent: string) => {
@@ -332,68 +351,250 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
loadMessages();
};
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
return (
<div className="chat-view">
{/* Top bar */}
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
<button
type="button"
className="chat-menu-btn"
onClick={onOpenSidebar}
aria-label="Open navigation"
>
<button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
☰
</button>
)}
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span>
<span>Generated {chatTotals.completion_tokens}</span>
<span>Total {chatTotals.total_tokens}</span>
{model && (
<div className="topbar-model-badge">
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
{tps !== null && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
</div>
<div className="chat-topbar-center">
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
</div>
)}
</div>
<div className="chat-topbar-right">
<label className="toggle-label">
<input type="checkbox" checked={useStream} onChange={(e) => setUseStream(e.target.checked)} />
Streaming
</label>
<label className="toggle-label">
<input type="checkbox" checked={useMemory} onChange={(e) => setUseMemory(e.target.checked)} />
Memory
</label>
{generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
<span>{streamTokenCount.toLocaleString()} tokens</span>
</div>
)}
{tps !== null && !generating && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
<button
type="button"
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
onClick={() => setShowMemoryModal(!showMemoryModal)}
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
aria-label="View memory context"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
{memoryMessageCount > 0 && (
<span className="memory-count-badge">{memoryMessageCount}</span>
)}
</button>
<div className="topbar-toggles">
<button
type="button"
className={`toggle-pill${useStream ? " on" : " off"}`}
onClick={() => setUseStream(!useStream)}
title={useStream ? "Streaming enabled" : "Streaming disabled"}
>
Stream
</button>
<button
type="button"
className={`toggle-pill${useMemory ? " on" : " off"}`}
onClick={() => setUseMemory(!useMemory)}
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
>
Memory
</button>
</div>
</div>
</div>
{/* Messages */}
<div className="messages-area">
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first coding workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="starter-chip"
onClick={() => handleStarterPrompt(prompt)}
>
{prompt}
</button>
))}
{/* ── Memory Modal ── */}
{showMemoryModal && (
<div className="memory-modal-overlay" onClick={() => setShowMemoryModal(false)}>
<div className="memory-modal" onClick={(e) => e.stopPropagation()}>
<div className="memory-modal-header">
<div className="memory-modal-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
<span>Memory Context</span>
</div>
<span className="memory-modal-subtitle">
{memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode
</span>
<button type="button" className="memory-modal-close" onClick={() => setShowMemoryModal(false)}>✕</button>
</div>
<div className="memory-modal-body">
{memoryMessages.length === 0 ? (
<div className="memory-empty">No messages in context yet. Start a conversation to populate memory.</div>
) : (
memoryMessages.map((msg, i) => (
<div key={i} className={`memory-item ${msg.role}`}>
<span className="memory-item-role">{msg.role}</span>
<span className="memory-item-content">
{msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content}
</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* ── Chat Layout ── */}
<div className="chat-layout-root">
{/* Scrollable message area */}
<div className="chat-scroll-area">
<div className="messages-container">
{chatId === null && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-panel">
<div className="empty-icon">⚡</div>
<span className="empty-kicker">Local-first AI workspace</span>
<h2>Start with a prompt that moves the project forward.</h2>
<p>
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
iterate, and debug without losing context.
</p>
<div className="empty-state-actions">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
{prompt}
</button>
))}
</div>
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
<div className="msg-bubble">
{(streamReasoning || streamThinkingOpen) && (
<details
className="msg-thinking"
open={showLiveReasoning}
onToggle={(event) => setShowLiveReasoning(event.currentTarget.open)}
>
<summary className="thinking-summary" aria-label="Toggle live thinking details">
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
<span className="reasoning-live-dot" aria-hidden="true" />
Thinking{streamThinkingOpen ? "..." : ""}
</summary>
{streamReasoning && (
<div
className="thinking-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
)}
</details>
)}
{streamContent && (
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
</div>
</div>
)}
<div ref={messagesEndRef} />
<div className="scroll-spacer" />
</div>
</div>
{/* ── Message Queue indicator ── */}
{messageQueue.length > 0 && (
<div className="queue-indicator">
<span className="queue-indicator-pulse" />
<span className="queue-indicator-text">
{messageQueue.length} message{messageQueue.length > 1 ? "s" : ""} queued
</span>
<button
type="button"
className="queue-clear-btn"
onClick={() => { messageQueueRef.current = []; setMessageQueue([]); }}
>
✕ Clear
</button>
</div>
)}
{/* ── Fixed Input ── */}
<div className="input-sticky-wrapper">
<div className="input-gradient-mask" />
<div className="input-content-constrained">
<div className="input-wrapper">
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
@@ -407,107 +608,107 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
)}
</div>
)}
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
renderMarkdown={renderMarkdown}
/>
))}
{/* Model capabilities — floating above the pill */}
<div className="model-capabilities" aria-label="Model capabilities">
<span className="capability-group">
<span className="label">In</span>
<ChatCircleText size={14} weight="duotone" />
<Image size={14} weight="duotone" />
</span>
<span className="separator">·</span>
<span className="capability-group">
<span className="label">Out</span>
<ChatCircleText size={14} weight="duotone" />
</span>
</div>
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
{(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowLiveReasoning((value) => !value)}
type="button"
>
<span>{showLiveReasoning ? "▼" : "▶"}</span>
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
</button>
{showLiveReasoning && streamReasoning && (
<div
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
{/* The typing pill */}
<div className="input-box">
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
</div>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Message..."
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<ArrowUp size={18} weight="duotone" />
</button>
)}
</div>
)}
{streamContent && (
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
{/* Queue system */}
{messageQueue.length > 0 && (
<div className="queue-container">
{isQueueOpen && (
<div className="queue-popover">
<div className="queue-header">
<span>Mensagens em espera</span>
</div>
<ul className="queue-list">
{messageQueue.map((msg, index) => (
<li key={index} className="queue-item">
<span className="queue-text">{msg}</span>
<button
className="queue-remove"
aria-label="Remover da fila"
onClick={() => {
const next = messageQueue.filter((_, i) => i !== index);
setQueue(next);
}}
>
✕
</button>
</li>
))}
</ul>
</div>
)}
<button
className="queue-toggle"
onClick={() => setIsQueueOpen(!isQueueOpen)}
>
{messageQueue.length} queued
</button>
</div>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="input-area">
{composerNotice && (
<div className={`composer-notice ${composerNotice.tone}`}>
<div>
<strong>{composerNotice.title}</strong>
<p>{composerNotice.description}</p>
</div>
{onOpenSidebar && (
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
{composerNotice.actionLabel}
</button>
)}
</div>
)}
<div className="input-row">
<textarea
ref={textareaRef}
className="input-textarea"
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={composerNotice ? `${composerNotice.title}. ${composerNotice.description}` : "Message… (Enter to send, Shift+Enter for new line)"}
rows={1}
disabled={generating}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop}>■ Stop</button>
) : (
<button className="send-btn" onClick={handleSend} disabled={!input.trim() || Boolean(composerNotice)}>
Send ↑
</button>
)}
</div>
<div className="input-meta">
<span className="char-count">{input.length} chars</span>
</div>
</div>
</div>
+248 -63
View File
@@ -1,7 +1,7 @@
/* Edit mode */
/* ── Edit mode ── */
.msg-edit-container {
width: 100%;
max-width: 780px;
max-width: 800px;
}
.msg-edit-textarea {
@@ -12,17 +12,19 @@
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
padding: 10px 14px;
padding: 11px 14px;
resize: none;
min-height: 80px;
outline: none;
line-height: 1.6;
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
}
.msg-edit-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.btn-primary {
@@ -31,13 +33,18 @@
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.btn-primary:hover { background: var(--accent2); }
.btn-primary:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.22);
}
.btn-secondary {
background: var(--bg4);
@@ -45,108 +52,286 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 14px;
min-height: 44px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.btn-secondary:hover { background: var(--bg5); color: var(--text); }
.btn-secondary:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
}
.btn-ghost {
background: none;
color: var(--text3);
border: none;
background: transparent;
color: var(--text2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 6px 10px;
min-height: 44px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: color 0.15s;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-ghost:hover { color: var(--text2); }
/* Message actions */
.reasoning-panel {
width: 100%;
margin-bottom: 8px;
.btn-ghost:hover { color: var(--text); background: rgba(237, 245, 255, 0.05); border-color: rgba(93, 114, 136, 0.28); }
.btn-ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(72, 166, 255, 0.18);
}
.reasoning-toggle {
display: inline-flex;
/* ── Thinking inside bubble ── */
.msg-thinking {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: transparent;
}
.msg-thinking > summary {
list-style: none;
}
.msg-thinking > summary::-webkit-details-marker {
display: none;
}
.thinking-summary {
display: flex;
align-items: center;
gap: 8px;
background: rgba(124, 158, 248, 0.08);
color: var(--text2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.75rem;
padding: 10px 16px;
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
user-select: none;
transition: color 0.2s ease-out;
}
.reasoning-toggle:hover {
background: rgba(124, 158, 248, 0.12);
color: var(--text);
.thinking-summary:hover {
color: rgba(255, 255, 255, 0.8);
}
.reasoning-content {
margin-top: 8px;
padding: 12px 14px;
background: rgba(124, 158, 248, 0.06);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text2);
font-size: 0.84rem;
.thinking-summary:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
}
.thinking-chevron {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 1.8;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.2s ease-out;
flex-shrink: 0;
}
.msg-thinking[open] .thinking-chevron {
transform: rotate(90deg);
}
.thinking-content {
padding: 4px 16px 16px 16px;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
font-style: normal;
}
.thinking-content > :first-child {
border-left: 2px solid rgba(255, 255, 255, 0.1);
margin-left: 16px;
padding-left: 12px;
}
/* ── Message bubble layout ── */
.msg-bubble {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.msg-content {
padding: 13px 16px;
}
/* ── Message metrics ── */
.message-metrics {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
color: var(--text3);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
padding-left: 12px;
font-size: 0.75rem;
opacity: 0.7;
font-family: monospace;
color: #a8b8cc;
}
.message-metrics span {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
/* ── Message footer ── */
.msg-footer {
display: flex;
justify-content: space-between;
align-items: stretch;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.12);
min-height: 32px;
}
/* ── Compact actions for user bubble (floating bar) ── */
.msg-wrapper.user {
position: relative;
margin-bottom: 24px;
}
.compact-actions {
position: absolute;
bottom: -12px;
right: 0;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 8px;
background: var(--bg2);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
transition: all 0.2s ease-out;
z-index: 10;
}
.msg-wrapper.user:hover .compact-actions,
.msg-wrapper.user:focus-within .compact-actions {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.compact-actions .action-btn {
min-height: 28px;
min-width: 28px;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: #c8d7ea;
}
.compact-actions .action-btn svg {
width: 14px;
height: 14px;
}
.compact-actions .action-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.compact-actions .action-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(72, 166, 255, 0.4);
opacity: 1;
}
.compact-actions .action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.12);
}
.compact-actions .action-btn.danger {
color: #f4c2c2;
}
.compact-actions .action-btn.danger:hover {
background: rgba(240, 64, 64, 0.12);
color: #ffd7d7;
}
/* ── Message actions (assistant) ── */
.msg-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.15s;
transition: opacity 0.2s ease-out;
}
.msg-bubble:hover .msg-actions,
.msg-bubble:focus-within .msg-actions {
opacity: 1;
}
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-wrapper:focus-within .msg-actions { opacity: 1; }
.action-btn {
background: none;
border: 1px solid var(--border);
color: var(--text2);
border-radius: var(--radius-sm);
padding: 3px 9px;
font-size: 0.72rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
min-width: 36px;
background: transparent;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.08);
color: #c8d7ea;
border-radius: 0;
padding: 0 12px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.2s ease-out, color 0.2s ease-out;
font-family: inherit;
white-space: nowrap;
}
.action-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border2); }
.action-btn.copied { color: var(--success); border-color: var(--success); }
.action-btn.danger:hover { background: rgba(240,64,64,0.1); color: var(--danger); border-color: var(--danger); }
.action-btn:first-child {
border-left: none;
}
.action-btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.action-btn:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(72, 166, 255, 0.3);
opacity: 1;
}
.action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.08);
}
.action-btn.danger {
color: #f4c2c2;
}
.action-btn.danger:hover {
background: rgba(240, 64, 64, 0.1);
color: #ffd7d7;
}
@media (hover: none), (max-width: 960px) {
.msg-actions { opacity: 1; }
}
@media (max-width: 640px) {
.msg-actions {
opacity: 1;
width: 100%;
}
.action-btn {
flex: 1 1 132px;
}
}
@media (prefers-reduced-motion: reduce) {
.action-btn,
.btn-primary,
.btn-secondary,
.btn-ghost {
animation: none;
transition: none;
}
}
@@ -0,0 +1,72 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MessageBubble from "./MessageBubble";
import type { Message } from "../lib/types";
describe("MessageBubble", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(() => Promise.resolve()),
},
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("renders accessible thinking and action controls for assistant messages", async () => {
const message: Message = {
id: 12,
chat_id: 4,
role: "assistant",
content: "Final answer",
reasoning_content: "Reasoning details",
prompt_tokens: 18,
completion_tokens: 24,
total_tokens: 42,
tokens_per_second: 6.4,
created_at: Date.now(),
};
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
const summary = container.querySelector("summary.reasoning-toggle");
expect(summary?.getAttribute("aria-label")).toBe("Toggle thinking details");
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.some((node) => node.getAttribute("aria-label") === "Copy message")).toBe(true);
expect(buttons.some((node) => node.getAttribute("aria-label") === "Edit message")).toBe(true);
expect(buttons.some((node) => node.getAttribute("aria-label") === "Delete message")).toBe(true);
expect(container.textContent).toContain("Sent 18");
expect(container.textContent).toContain("Generated 24");
expect(container.textContent).toContain("Total 42");
expect(container.textContent).toContain("6.4 tok/s");
});
});
+93 -47
View File
@@ -14,10 +14,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const reasoningRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLDivElement>(null);
// Wire up copy-code buttons inside rendered markdown
useEffect(() => {
@@ -36,8 +35,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
});
useEffect(() => {
if (!reasoningRef.current) return;
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
if (!thinkingRef.current) return;
const buttons = thinkingRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
@@ -90,6 +89,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
(message.total_tokens ?? 0) > 0
);
const showFooter = message.role === "assistant";
return (
<div className={`msg-wrapper ${message.role}`}>
{editing ? (
@@ -118,56 +119,101 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
) : (
<>
{hasReasoning && (
<div className="reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowReasoning((value) => !value)}
type="button"
>
<span>{showReasoning ? "▼" : "▶"}</span>
<span>Thinking</span>
</button>
{showReasoning && (
<div
ref={reasoningRef}
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
/>
)}
</div>
)}
{message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
) : (
<div className="msg-bubble">{message.content}</div>
)}
>
{hasReasoning && (
<details className="msg-thinking">
<summary className="thinking-summary">
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
Thinking
</summary>
<div
className="thinking-content"
ref={thinkingRef}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
/>
</details>
)}
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
{showFooter && (
<div className="msg-footer">
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div>
)}
{!hasUsage && <div />}
<div className="msg-actions" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
<>
<div className="msg-bubble user-bubble">
<div className="msg-content">{message.content}</div>
</div>
<div className="msg-actions compact-actions" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</>
)}
<div className="msg-actions">
<button className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage}>
{copied ? "✓ Copied" : "⎘ Copy"}
</button>
<button className="action-btn" onClick={startEdit}>
✎ Edit{onEditAndResend ? " & Resend" : ""}
</button>
<button className="action-btn danger" onClick={() => onDelete(message.id)}>
✕ Delete
</button>
</div>
</>
)}
</div>
+52 -125
View File
@@ -1,9 +1,7 @@
.sidebar {
width: 280px;
min-width: 280px;
background:
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
var(--bg2);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -13,7 +11,7 @@
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
}
.sidebar-close-btn {
@@ -24,7 +22,7 @@
.sidebar-header {
display: flex;
align-items: center;
padding: 14px 14px 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
@@ -33,32 +31,15 @@
.sidebar-brand {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
}
.brand-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(91,106,240,0.35);
}
.brand-name {
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #f5fbff, #89b5d9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.icon-btn {
@@ -66,16 +47,15 @@
border: none;
color: var(--text3);
cursor: pointer;
font-size: 15px;
width: 36px;
height: 36px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
padding: 0;
}
.icon-btn:hover {
@@ -83,61 +63,16 @@
color: var(--text);
}
/* ── Settings panel ── */
.settings-panel {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
background: var(--bg3);
animation: slide-down 0.15s ease;
flex-shrink: 0;
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-label {
font-size: 10px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 4px;
}
.settings-input {
background: var(--bg4);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.settings-input:focus {
border-color: var(--accent);
background: var(--bg5);
}
/* ── Model section ── */
.model-section {
padding: 8px 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.model-section-label {
font-size: 10px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -147,14 +82,7 @@
.model-row {
display: flex;
align-items: center;
gap: 7px;
}
.model-help {
margin-top: 8px;
font-size: 11px;
color: var(--text3);
line-height: 1.5;
gap: 8px;
}
.status-dot {
@@ -186,56 +114,67 @@
.model-select {
flex: 1;
background: var(--bg4);
border: 1px solid var(--border);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
padding: 6px 28px 6px 9px;
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s;
transition: border-color 0.15s, background 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.model-select:hover {
background-color: var(--bg3);
border-color: var(--border);
}
.model-select:focus {
border-color: var(--accent);
background-color: var(--bg3);
}
.model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 10px 12px 6px;
padding: 10px 16px 6px;
flex-shrink: 0;
}
.new-chat-btn {
width: 100%;
background: linear-gradient(135deg, #2b88e6, #49c0ff);
color: #fff;
border: none;
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 12px;
padding: 8px 12px;
font-size: 13px;
font-weight: 700;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
justify-content: flex-start;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: inherit;
letter-spacing: 0.01em;
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
height: 36px;
}
.new-chat-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
background: var(--bg4);
color: var(--text);
border-color: var(--border);
}
.new-chat-btn:active {
@@ -244,12 +183,12 @@
/* ── Chat list ── */
.chat-list-section-label {
font-size: 10px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 12px 4px;
padding: 12px 16px 4px;
flex-shrink: 0;
}
@@ -279,7 +218,6 @@
color: var(--text2);
transition: background 0.12s, color 0.12s;
position: relative;
border-left: 2px solid transparent;
}
.chat-item-button {
@@ -287,7 +225,6 @@
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 0;
@@ -295,6 +232,7 @@
cursor: pointer;
font: inherit;
text-align: left;
border-radius: var(--radius-sm);
}
.chat-item:hover {
@@ -308,19 +246,8 @@
}
.chat-item.active {
background: rgba(91,106,240,0.1);
background: var(--bg4);
color: var(--text);
border-left-color: var(--accent);
}
.chat-item-icon {
font-size: 13px;
flex-shrink: 0;
opacity: 0.5;
}
.chat-item.active .chat-item-icon {
opacity: 0.8;
}
.chat-item-title {
@@ -329,6 +256,7 @@
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 1.4;
}
.chat-item-del {
@@ -337,7 +265,6 @@
border: none;
color: var(--text3);
cursor: pointer;
font-size: 12px;
width: 28px;
height: 28px;
display: flex;
@@ -346,8 +273,8 @@
border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
margin-right: 6px;
padding: 0;
margin-right: 4px;
}
.chat-item:hover .chat-item-del {
@@ -366,7 +293,7 @@
/* ── Footer ── */
.sidebar-footer {
padding: 8px 14px;
padding: 8px 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
+14 -48
View File
@@ -1,7 +1,5 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import RuntimeWorkspace from "../features/runtime/components/RuntimeWorkspace";
import type { RuntimeConfig, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot } from "../features/runtime/types";
import { Plus, Settings, X, Trash2, RefreshCw } from "lucide-react";
import "./Sidebar.css";
interface Props {
@@ -15,59 +13,33 @@ interface Props {
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
runtime: {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
draftOpen: boolean;
draftFields: RuntimeDraftFieldState;
loading: boolean;
busy: boolean;
activeLifecycleCommand: RuntimeSnapshot["active_command"];
lastLifecycleResult: RuntimeSnapshot["last_command_result"];
lifecycleBusy: boolean;
applyImpact: "immediate" | "restart_required" | "save_before_apply";
openDraft: () => void;
closeDraft: () => void;
resetDraft: () => void;
updateDraft: (patch: Partial<RuntimeConfig>) => void;
updateDraftFieldMeta: (field: keyof RuntimeDraftFieldState & string, patch: Partial<RuntimeDraftFieldMeta>) => void;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
};
onOpenSettings: () => void;
onCloseMobile: () => void;
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
models, selectedModel, onModelChange, status, onRefreshModels,
runtime,
onOpenSettings,
onCloseMobile,
}: Props) {
const modelCountLabel = models.length === 1 ? "1 model available" : `${models.length} models available`;
return (
<>
<aside className="sidebar">
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-icon">⚡</div>
<span className="brand-name">ForgeLM</span>
</div>
<div className="sidebar-header-actions">
<button
className="icon-btn"
onClick={() => {
runtime.openDraft();
}}
title="Runtime settings"
aria-label="Open runtime settings"
onClick={onOpenSettings}
title="Settings"
aria-label="Open settings"
type="button"
>
⚙
<Settings size={16} strokeWidth={1.5} />
</button>
<button
className="icon-btn sidebar-close-btn"
@@ -76,17 +48,14 @@ export default function Sidebar({
aria-label="Close navigation"
type="button"
>
✕
<X size={16} strokeWidth={1.5} />
</button>
</div>
</div>
{/* ── Unified Runtime Workspace ── */}
<RuntimeWorkspace runtime={runtime} />
{/* ── Active model selector ── */}
<div className="model-section">
<div className="model-section-label">Active model</div>
<div className="model-section-label">Model</div>
<div className="model-row">
<div className={`status-dot status-${status}`} title={status} aria-hidden="true" />
<select
@@ -94,7 +63,7 @@ export default function Sidebar({
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={models.length === 0}
title={models.length === 0 ? "Start the server to load models" : ""}
title={models.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
>
{models.length === 0 ? (
@@ -112,18 +81,16 @@ export default function Sidebar({
aria-label="Refresh models"
type="button"
>
↻
<RefreshCw size={14} strokeWidth={1.5} />
</button>
</div>
<div className="model-help">
{models.length === 0 ? "Start the runtime to load available models." : modelCountLabel}
</div>
</div>
{/* ── New chat ── */}
<div className="sidebar-actions">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<span>+</span> New Chat
<Plus size={16} strokeWidth={1.5} />
<span>New Chat</span>
</button>
</div>
@@ -148,7 +115,6 @@ export default function Sidebar({
onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined}
>
<span className="chat-item-icon">💬</span>
<span className="chat-item-title">{chat.title}</span>
</button>
<button
@@ -158,7 +124,7 @@ export default function Sidebar({
title="Delete conversation"
aria-label={`Delete conversation ${chat.title}`}
>
✕
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
@@ -16,6 +16,7 @@ import {
resolveDraftModelSelection,
toModelFile,
} from "../runtime-settings-form";
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime-defaults";
import {
createRuntimeDraftFieldState,
type ModelLibraryState,
@@ -31,7 +32,6 @@ import {
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
const REMOTE_PRESET_TEMPLATES = [
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
@@ -799,10 +799,10 @@ export default function RuntimeSettingsDrawer({
<input
id="runtime-context"
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
min={512}
min={MIN_CTX_SIZE}
step={512}
value={draftConfig.ctx_size}
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
onChange={(event) => updateDraft({ ctx_size: Math.max(MIN_CTX_SIZE, parseInt(event.target.value, 10) || DEFAULT_CTX_SIZE) })}
type="number"
/>
</div>
@@ -28,6 +28,11 @@
gap: 10px;
}
.runtime-card-header-left {
min-width: 0;
flex: 1;
}
.runtime-card-kicker {
font-size: 10px;
font-weight: 700;
@@ -42,6 +47,25 @@
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
display: flex;
align-items: center;
gap: 7px;
}
/* Pulsing green dot when runtime is running */
.runtime-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #30d158;
flex-shrink: 0;
animation: runtime-pulse 2s ease-in-out infinite;
}
@keyframes runtime-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(48, 209, 88, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(48, 209, 88, 0); }
}
.runtime-card-mode {
@@ -92,6 +116,13 @@
padding: 7px 10px;
}
.runtime-card-meta-model {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-card-note {
display: block;
margin-top: 10px;
@@ -177,12 +208,63 @@
border-color: var(--accent);
}
.runtime-card-btn-primary {
/* Start — green */
.runtime-card-btn-start {
color: #fff;
border-color: rgba(48, 209, 88, 0.5);
background: linear-gradient(135deg, #1ea84d, #30d158);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-start:hover:not(:disabled) {
background: linear-gradient(135deg, #25c05a, #3fe068);
}
/* Stop — red */
.runtime-card-btn-stop {
color: #fff;
border-color: rgba(240, 64, 64, 0.5);
background: linear-gradient(135deg, #c0302e, #f04040);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-stop:hover:not(:disabled) {
background: linear-gradient(135deg, #d03838, #f55555);
}
/* Setup — blue (same as primary) */
.runtime-card-btn-setup {
color: #fff;
border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.runtime-card-btn-primary:hover:not(:disabled) {
.runtime-card-btn-setup:hover:not(:disabled) {
background: linear-gradient(135deg, #3193f5, #55cbff);
}
/* Spinner for in-progress lifecycle */
.runtime-btn-spinner {
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: runtime-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes runtime-spin {
to { transform: rotate(360deg); }
}
@@ -62,26 +62,35 @@ export default function RuntimeStatusCard({
? "runtime-card-result-warning"
: "runtime-card-result-success";
const isRunning = isLocal && snapshot.process_running;
const isStopping = isLocal && snapshot.status === "stopping";
const primaryVariant = needsSetup ? "setup" : isRunning ? "stop" : "start";
return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header">
<div>
<div className="runtime-card-header-left">
<div className="runtime-card-kicker">Runtime Manager</div>
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
<div className="runtime-card-title">
{isRunning && !isStopping && <span className="runtime-status-dot" aria-hidden="true" />}
{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}
</div>
</div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span>
</div>
<p className="runtime-card-copy">
<p className="runtime-card-copy" title={snapshot.config.connection_mode === "local" ? snapshot.resolved_url ?? "" : snapshot.config.server_url ?? ""}>
{snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"}
</p>
<div className="runtime-card-meta" aria-label="Runtime details">
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
<span className="runtime-card-meta-model" title={snapshot.config.model_path ?? ""}>
{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}
</span>
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div>
@@ -104,7 +113,13 @@ export default function RuntimeStatusCard({
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
<button
className={`runtime-card-btn runtime-card-btn-${primaryVariant}`}
onClick={primaryAction}
type="button"
disabled={primaryDisabled}
>
{lifecycleBusy ? <span className="runtime-btn-spinner" aria-hidden="true" /> : null}
{primaryLabel}
</button>
)}
@@ -0,0 +1,3 @@
export const MIN_CTX_SIZE = 512;
export const DEFAULT_CTX_SIZE = 4096;
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
@@ -9,6 +9,7 @@ import {
toModelFile,
type RuntimeSettingsFormState,
} from "./runtime-settings-form";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import {
createRuntimeDraftState,
@@ -185,6 +186,17 @@ describe("runtime settings form helpers", () => {
});
});
it("falls back to the shared desktop default context size when the draft value is empty", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
snapshot.config,
createFormState({ ctxSize: 0 }),
32,
);
expect(patch.ctx_size).toBe(DEFAULT_CTX_SIZE);
});
it("keeps saved local values untouched when editing remote mode", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
@@ -4,6 +4,7 @@ import type {
RuntimeModelFile,
StartPolicy,
} from "./types";
import { DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "./runtime-defaults";
function normalizeUrl(value: string): string {
const trimmed = value.trim();
@@ -126,7 +127,7 @@ export function createRuntimeConfigPatch(
form.mode === "local"
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
: snapshotConfig.gpu_layers,
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
ctx_size: form.mode === "local" ? Math.max(MIN_CTX_SIZE, form.ctxSize || DEFAULT_CTX_SIZE) : snapshotConfig.ctx_size,
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
extra_model_paths:
form.mode === "local"
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import {
createDefaultRuntimeSnapshot,
createRuntimeLifecycleResult,
getRuntimeConnection,
getRuntimeStartGuard,
getRuntimeStateLabel,
getRuntimeStatusTone,
} from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
@@ -42,6 +50,10 @@ function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
}
describe("getRuntimeConnection", () => {
it("creates local snapshots with the shared desktop default context window", () => {
expect(createDefaultRuntimeSnapshot().config.ctx_size).toBe(DEFAULT_CTX_SIZE);
});
it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000",
@@ -90,6 +102,10 @@ describe("getRuntimeStateLabel", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
});
it("reports local processes that lost readiness as not ready", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("Not ready");
});
it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
@@ -113,6 +129,12 @@ describe("getRuntimeStateLabel", () => {
});
});
describe("getRuntimeStatusTone", () => {
it("marks local not-ready snapshots as danger", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
});
});
describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({
@@ -13,6 +13,7 @@ import type {
RuntimeWorkspaceSummary,
} from "./types";
import { scanRuntimeModels } from "./api";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
@@ -179,6 +180,10 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
return "Remote API";
}
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "Not ready" : "Local API offline";
}
switch (snapshot.status) {
case "ready":
return "Ready";
@@ -200,6 +205,10 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
return "neutral";
}
if (snapshot.status === "ready" && !snapshot.ready) {
return snapshot.process_running ? "danger" : "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
@@ -299,7 +308,7 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,922 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
searchHuggingFaceModels,
} from "../runtime/api";
import {
dedupeModels,
pathName,
toModelFile,
} from "../runtime/runtime-settings-form";
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime/runtime-defaults";
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime/runtime-utils";
import type {
HuggingFaceModelResult,
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimeSnapshot,
} from "../runtime/types";
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download } from "lucide-react";
import "./SettingsPage.css";
// ─── Types ───────────────────────────────────────────────────────────────────
type Section = "model" | "runtime" | "connection" | "advanced";
interface SettingsRuntime {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
loading: boolean;
busy: boolean;
lifecycleBusy: boolean;
scannedModels: RuntimeModelFile[];
isScanningModels: boolean;
updateDraft: (patch: Partial<RuntimeConfig>) => void;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
scanModels: (dir: string | null) => Promise<void>;
resetDraft: () => void;
}
interface Props {
runtime: SettingsRuntime;
onClose: () => void;
}
// ─── Nav definition ──────────────────────────────────────────────────────────
const NAV: { id: Section; label: string; icon: typeof Box }[] = [
{ id: "model", label: "Model", icon: Box },
{ id: "runtime", label: "Runtime", icon: Zap },
{ id: "connection", label: "Connection", icon: ArrowLeftRight },
{ id: "advanced", label: "Advanced", icon: Settings2 },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return "";
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MiB`;
return `${(bytes / 1024).toFixed(0)} KiB`;
}
function statusColor(snapshot: RuntimeSnapshot): string {
const tone = getRuntimeStatusTone(snapshot);
if (tone === "success") return "sp-dot--success";
if (tone === "warning") return "sp-dot--warn";
if (tone === "danger") return "sp-dot--danger";
return "sp-dot--idle";
}
function statusPillClass(snapshot: RuntimeSnapshot): string {
const tone = getRuntimeStatusTone(snapshot);
if (tone === "success") return "sp-status-pill--success";
if (tone === "warning") return "sp-status-pill--warn";
if (tone === "danger") return "sp-status-pill--danger";
return "";
}
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage({ runtime, onClose }: Props) {
const {
snapshot, savedBaseline, draftConfig, draftDirty,
loading, busy, lifecycleBusy,
scannedModels, isScanningModels,
updateDraft, saveConfig, startLocal, stopLocal, scanModels, resetDraft,
} = runtime;
const [section, setSection] = useState<Section>("model");
const [hfQuery, setHfQuery] = useState("");
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
const [hfLoading, setHfLoading] = useState(false);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [localError, setLocalError] = useState("");
const [saving, setSaving] = useState(false);
const didAutoScan = useRef(false);
const busyAll = busy || lifecycleBusy || saving;
// ── Side-effects ──────────────────────────────────────────────────────────
useEffect(() => {
queryRuntimeGpu().then(setGpuInfo).catch(() => undefined);
}, []);
useEffect(() => {
if (section !== "model" || didAutoScan.current) return;
const dir = draftConfig.models_dir?.trim();
if (dir && scannedModels.length === 0 && !isScanningModels) {
didAutoScan.current = true;
void scanModels(dir);
}
}, [section, draftConfig.models_dir, scannedModels.length, isScanningModels, scanModels]);
// ── Derived data ──────────────────────────────────────────────────────────
const allModels = dedupeModels([
...scannedModels,
...draftConfig.extra_model_paths.map(toModelFile),
]);
const isLocal = draftConfig.connection_mode === "local";
const isRunning = snapshot.process_running;
const isStopping = snapshot.status === "stopping";
const runtimeStatusLabel = loading ? "Loading..." : getRuntimeStateLabel(snapshot);
// ── Handlers ─────────────────────────────────────────────────────────────
const handlePickDir = useCallback(async () => {
setLocalError("");
try {
const result = await pickRuntimeModelsDir();
if (!result) return;
updateDraft({ models_dir: result.dir });
void scanModels(result.dir);
} catch (e) {
setLocalError(String(e));
}
}, [updateDraft, scanModels]);
const handlePickFiles = useCallback(async () => {
setLocalError("");
try {
const picked = await pickRuntimeModelFiles();
if (picked.length === 0) return;
const next = dedupeModels([
...draftConfig.extra_model_paths.map(toModelFile),
...picked,
]);
updateDraft({
extra_model_paths: next.map((m) => m.path),
model_path: draftConfig.model_path || next[0]?.path || null,
});
} catch (e) {
setLocalError(String(e));
}
}, [draftConfig, updateDraft]);
const handleSelectModel = useCallback((path: string) => {
updateDraft({ model_path: path, selected_model_id: null });
}, [updateDraft]);
const handleRemoveExtra = useCallback((path: string) => {
const next = draftConfig.extra_model_paths.filter((p) => p !== path);
updateDraft({
extra_model_paths: next,
model_path: draftConfig.model_path === path
? (next[0] ?? scannedModels[0]?.path ?? null)
: draftConfig.model_path,
});
}, [draftConfig, scannedModels, updateDraft]);
const handleHfSearch = useCallback(async () => {
if (!hfQuery.trim()) return;
setHfLoading(true);
setLocalError("");
try {
const results = await searchHuggingFaceModels(hfQuery, draftConfig.hugging_face_token || "");
setHfResults(results);
} catch (e) {
setLocalError(String(e));
} finally {
setHfLoading(false);
}
}, [hfQuery, draftConfig.hugging_face_token]);
const handleSave = useCallback(async () => {
setSaving(true);
setLocalError("");
try {
await saveConfig(draftConfig);
} catch (e) {
setLocalError(String(e));
} finally {
setSaving(false);
}
}, [saveConfig, draftConfig]);
const handleSaveAndStart = useCallback(async () => {
setSaving(true);
setLocalError("");
try {
await saveConfig(draftConfig);
await startLocal();
} catch (e) {
setLocalError(String(e));
} finally {
setSaving(false);
}
}, [saveConfig, draftConfig, startLocal]);
const handleStop = useCallback(async () => {
setLocalError("");
try {
await stopLocal();
} catch (e) {
setLocalError(String(e));
}
}, [stopLocal]);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="sp-shell flex h-full overflow-hidden">
{/* ── Left navigation ──────────────────────────────────────────── */}
<nav className="sp-nav">
<div className="sp-nav-header">
<div className="sp-nav-brand">ForgeLM</div>
<h2 className="sp-nav-title">Settings</h2>
</div>
<div className="sp-nav-items">
{NAV.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => setSection(item.id)}
className={`sp-nav-item${section === item.id ? " sp-nav-item--active" : ""}`}
>
<Icon size={16} strokeWidth={1.5} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div>
{/* Runtime status pill */}
<div className="sp-nav-status">
<span className={`sp-dot ${statusColor(snapshot)}`} />
<span className="text-xs sp-nav-status-label">
{runtimeStatusLabel}
</span>
</div>
<div className="sp-nav-footer">
<button type="button" onClick={onClose} className="sp-back-btn">
← Back to Chat
</button>
</div>
</nav>
{/* ── Main area (right side) ─────────────────────────────────── */}
<div className="settings-main-area">
<div className="settings-scroll-container">
<div className="settings-content">
{/* ═══════════════════════════════════ MODEL SECTION ═══════════════════════════ */}
{section === "model" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Model</h2>
<p className="sp-section-desc">
Select a local GGUF model file for inference.
</p>
</div>
{isRunning && (
<span className={`sp-status-pill ${statusPillClass(snapshot)}`}>
<span className="sp-dot sp-dot--inline" />
{isStopping ? "Stopping local API" : getRuntimeStateLabel(snapshot)}
</span>
)}
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
{/* Active model */}
<div className="sp-card">
{snapshot.config.model_path ? (
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Active model</div>
<div className="sp-card-row-desc">Currently loaded GGUF file</div>
</div>
<div className="sp-card-row-control">
<div className="sp-model-name">{pathName(snapshot.config.model_path)}</div>
<div className="sp-model-path">{snapshot.config.model_path}</div>
{draftDirty && draftConfig.model_path !== snapshot.config.model_path && (
<div className="sp-pending mt-2">
Draft → {pathName(draftConfig.model_path) || "None selected"}
</div>
)}
</div>
</div>
) : (
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Active model</div>
<div className="sp-card-row-desc">No model selected yet</div>
</div>
<div className="sp-card-row-control">
<p className="sp-empty">Use the sections below to load one.</p>
</div>
</div>
)}
{/* Scan folder */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Scan folder</div>
<div className="sp-card-row-desc">Browse and scan a directory for .gguf files</div>
</div>
<div className="sp-card-row-control">
<div className="sp-folder-actions">
<button
type="button"
className="sp-btn sp-btn--secondary"
onClick={() => void handlePickDir()}
disabled={busyAll}
>
Browse…
</button>
{draftConfig.models_dir && (
<button
type="button"
className="sp-btn sp-btn--ghost"
onClick={() => { void scanModels(draftConfig.models_dir!); }}
disabled={busyAll || isScanningModels}
>
{isScanningModels ? "Scanning…" : "Rescan"}
</button>
)}
</div>
{draftConfig.models_dir && (
<div className="sp-folder-path">{draftConfig.models_dir}</div>
)}
</div>
</div>
{/* Model list from scan */}
{(isScanningModels || scannedModels.length > 0) && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
{isScanningModels && scannedModels.length === 0 && (
<div className="sp-scanning-row">Scanning for models…</div>
)}
<div className="sp-model-list">
{scannedModels.map((model) => {
const selected = draftConfig.model_path === model.path;
return (
<button
key={model.path}
type="button"
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}`}
onClick={() => handleSelectModel(model.path)}
disabled={busyAll}
>
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
<span className="flex-1 truncate sp-model-row-name">{model.name}</span>
</button>
);
})}
</div>
</div>
</>
)}
{/* Browse individual files */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Individual files</div>
<div className="sp-card-row-desc">Add specific .gguf files without scanning a folder</div>
</div>
<div className="sp-card-row-control">
<button
type="button"
className="sp-btn sp-btn--secondary"
onClick={() => void handlePickFiles()}
disabled={busyAll}
>
Pick files…
</button>
{draftConfig.extra_model_paths.length > 0 && (
<div className="sp-model-list mt-3">
{draftConfig.extra_model_paths.map((p) => {
const selected = draftConfig.model_path === p;
return (
<div
key={p}
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}${busyAll ? " sp-model-row--disabled" : ""}`}
role="row"
>
<button
type="button"
className="sp-model-row-select"
onClick={() => handleSelectModel(p)}
disabled={busyAll}
aria-pressed={selected}
>
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
<span className="flex-1 truncate sp-model-row-name">{pathName(p)}</span>
</button>
<button
type="button"
className="sp-remove-btn"
onClick={() => handleRemoveExtra(p)}
disabled={busyAll}
aria-label="Remove file"
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* HuggingFace search */}
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
<div className="sp-hf-search-header">
<div>
<div className="sp-card-row-title">Search HuggingFace</div>
<div className="sp-card-row-desc">
{!draftConfig.hugging_face_token && "Add HF token in Advanced for private models"}
</div>
</div>
<span className="tooltip-container">
<Info size={14} className="sp-hf-info-icon" />
<div className="sp-tooltip-content">
<div className="sp-hf-tooltip-title">O que é Quantização?</div>
<p className="sp-hf-tooltip-body">
É a compressão do modelo para que ele rode mais rápido e caiba na memória (RAM/VRAM) do seu PC.
</p>
<div className="sp-hf-tooltip-levels">
<span><span className="sp-hf-dot sp-hf-dot--green" /> <strong>Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.</span>
<span><span className="sp-hf-dot sp-hf-dot--blue" /> <strong>Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício entre velocidade e resposta.</span>
<span><span className="sp-hf-dot sp-hf-dot--yellow" /> <strong>Alta Compressão (Leve):</strong> Q2_K, Q3_K_M — Roda em PCs fracos, mas o modelo fica mais "esquecido" ou impreciso.</span>
</div>
</div>
</span>
</div>
<div className="sp-hf-search-bar">
<input
className="sp-input sp-hf-input"
placeholder="Search for GGUF models…"
value={hfQuery}
onChange={(e) => setHfQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
/>
<button
type="button"
className="sp-btn sp-btn--primary"
onClick={() => void handleHfSearch()}
disabled={hfLoading || !hfQuery.trim()}
>
<Search size={14} strokeWidth={2} />
{hfLoading ? "…" : "Search"}
</button>
</div>
{hfResults.length > 0 && (
<div className="sp-hf-results">
{hfResults.map((result) => (
<div key={result.repo_id} className="sp-hf-card">
<div className="sp-hf-card-header">
<div className="sp-hf-repo">{result.repo_id}</div>
<div className="sp-hf-author">{result.author || "Unknown author"}</div>
</div>
{result.verified_files.length > 0 && (
<div className="sp-hf-files">
{result.verified_files.map((file) => {
const fileName = file.path.replace(/\.gguf$/i, "");
const fileSize = file.size ? `${(file.size / 1_073_741_824).toFixed(1)} GB` : "—";
return (
<div key={`${result.repo_id}:${file.path}`} className="sp-hf-file">
<span className="sp-hf-name truncate" title={file.path}>{fileName}</span>
<span className="sp-hf-size">{fileSize}</span>
<span className="sp-hf-quant-badge">{file.quantization || "gguf"}</span>
<button
type="button"
className="sp-hf-download-btn"
title={`Download ${file.path}`}
onClick={() => {
// TODO: wire up actual download
console.log("Download:", file.path);
}}
>
<Download size={16} strokeWidth={2} />
</button>
</div>
);
})}
</div>
)}
</div>
))}
</div>
)}
{!hfLoading && hfQuery && hfResults.length === 0 && (
<p className="sp-hint mt-3">No results. Try a different query.</p>
)}
</div>
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ RUNTIME SECTION ════════════════════════ */}
{section === "runtime" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Runtime</h2>
<p className="sp-section-desc">GPU acceleration, context window, and startup policy.</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* GPU acceleration */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">GPU acceleration</div>
<div className="sp-card-row-desc">
{gpuInfo ? `${gpuInfo.name} · ${gpuInfo.vram_mib} MiB VRAM` : "No GPU detected — CPU only"}
</div>
</div>
<div className="sp-card-row-control">
{gpuInfo && (
<span className="sp-status-pill sp-status-pill--success">GPU detected</span>
)}
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">GPU layers</div>
<div className="sp-card-row-desc">Layers to offload to the GPU</div>
</div>
<div className="sp-card-row-control sp-card-row-control--wide">
<div className="sp-field-value">{draftConfig.gpu_layers}</div>
<input
type="range"
min={0}
max={gpuInfo ? Math.max(gpuInfo.recommended_layers, draftConfig.gpu_layers, 64) : 64}
value={draftConfig.gpu_layers}
onChange={(e) => updateDraft({ gpu_layers: Number(e.target.value) })}
className="sp-slider w-full mt-2"
disabled={busyAll}
/>
<div className="sp-slider-presets">
<button
type="button"
className="sp-btn-xs"
onClick={() => updateDraft({ gpu_layers: 0 })}
>
0 — CPU only
</button>
{gpuInfo && (
<button
type="button"
className="sp-btn-xs"
onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })}
>
{gpuInfo.recommended_layers} — Recommended
</button>
)}
</div>
</div>
</div>
{/* Context window */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Context window</div>
<div className="sp-card-row-desc">Maximum tokens the model can process</div>
</div>
<div className="sp-card-row-control sp-card-row-control--wide">
<div className="sp-field-value">{draftConfig.ctx_size.toLocaleString()} tokens</div>
<div className="sp-segmented">
{CTX_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => updateDraft({ ctx_size: preset })}
className={`sp-segmented-btn${draftConfig.ctx_size === preset ? " sp-segmented-btn--active" : ""}`}
disabled={busyAll}
>
{preset >= 1024 ? `${preset / 1024}K` : preset}
</button>
))}
</div>
<div className="sp-ctx-custom">
<input
type="number"
className="sp-input sp-input--sm"
value={draftConfig.ctx_size}
min={MIN_CTX_SIZE}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= MIN_CTX_SIZE) updateDraft({ ctx_size: v });
}}
disabled={busyAll}
/>
<span className="sp-hint">Min {MIN_CTX_SIZE.toLocaleString()}</span>
</div>
</div>
</div>
{/* Startup policy */}
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Startup policy</div>
<div className="sp-card-row-desc">When to start the local API</div>
</div>
<div className="sp-card-row-control">
<div className="sp-toggles">
{(["manual", "on_app_launch"] as const).map((policy) => {
const active = draftConfig.start_policy === policy;
return (
<label key={policy} className={`sp-toggle${active ? " sp-toggle--active" : ""}`}>
<input
type="checkbox"
checked={active}
onChange={() => updateDraft({ start_policy: policy })}
disabled={busyAll}
/>
<span className="sp-toggle-track" />
<span className="sp-toggle-label">
{policy === "manual" ? "Manual" : "Auto-start"}
</span>
</label>
);
})}
</div>
<div className="sp-toggle-desc">
{draftConfig.start_policy === "manual"
? "Start the local API from the runtime panel when needed."
: "Automatically start the local API when ForgeLM opens."}
</div>
</div>
</div>
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ CONNECTION SECTION ═════════════════════ */}
{section === "connection" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Connection</h2>
<p className="sp-section-desc">
Use the built-in local inference engine or connect to a remote OpenAI-compatible server.
</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* Mode */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Mode</div>
<div className="sp-card-row-desc">
{draftConfig.connection_mode === "local"
? "Runs the inference server locally on your machine."
: "Forwards all requests to an external OpenAI-compatible endpoint."}
</div>
</div>
<div className="sp-card-row-control">
<div className="sp-segmented">
{(["local", "remote"] as const).map((mode) => {
const active = draftConfig.connection_mode === mode;
return (
<button
key={mode}
type="button"
className={`sp-segmented-btn${active ? " sp-segmented-btn--active" : ""}`}
onClick={() => updateDraft({ connection_mode: mode })}
disabled={busyAll}
>
{mode === "local" ? "Local API" : "Remote API"}
</button>
);
})}
</div>
</div>
</div>
{/* Local settings */}
{draftConfig.connection_mode === "local" && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Bind address</div>
<div className="sp-card-row-desc">Host and port for the local API</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
value={draftConfig.addr}
onChange={(e) => updateDraft({ addr: e.target.value })}
placeholder="127.0.0.1:8080"
disabled={busyAll}
/>
<p className="sp-hint mt-2">
Available at{" "}
<code>http://{draftConfig.addr || "127.0.0.1:8080"}</code>
</p>
</div>
</div>
</>
)}
{/* Remote settings */}
{draftConfig.connection_mode === "remote" && (
<>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Server URL</div>
<div className="sp-card-row-desc">Base URL of the remote API</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
value={draftConfig.server_url}
onChange={(e) => updateDraft({ server_url: e.target.value })}
placeholder="https://api.openai.com/v1"
disabled={busyAll}
/>
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">API key</div>
<div className="sp-card-row-desc">Authentication key for the remote server</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
type="password"
value={draftConfig.api_key}
onChange={(e) => updateDraft({ api_key: e.target.value })}
placeholder="sk-…"
disabled={busyAll}
autoComplete="off"
/>
</div>
</div>
</>
)}
</div>
</div>
</>
)}
{/* ═══════════════════════════════════ ADVANCED SECTION ═══════════════════════ */}
{section === "advanced" && (
<>
<header className="page-header sp-section-header">
<div>
<h2 className="sp-section-title">Advanced</h2>
<p className="sp-section-desc">HuggingFace access and diagnostic information.</p>
</div>
</header>
<div className="settings-cards">
{localError && <div className="sp-error-inline">{localError}</div>}
<div className="sp-card">
{/* HF Token */}
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">HuggingFace token</div>
<div className="sp-card-row-desc">Required for gated or private models</div>
</div>
<div className="sp-card-row-control">
{draftConfig.hugging_face_token && (
<span className="sp-status-pill sp-status-pill--success">Configured</span>
)}
</div>
</div>
<div className="sp-card-divider" />
<div className="sp-card-row">
<div className="sp-card-row-label">
<div className="sp-card-row-title">Token value</div>
<div className="sp-card-row-desc">Generate at huggingface.co/settings/tokens</div>
</div>
<div className="sp-card-row-control">
<input
className="sp-input w-full"
type="password"
value={draftConfig.hugging_face_token || ""}
onChange={(e) => updateDraft({ hugging_face_token: e.target.value })}
placeholder="hf_…"
disabled={busyAll}
autoComplete="off"
/>
</div>
</div>
{/* Diagnostics */}
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
<div className="sp-card-row-title">Diagnostics</div>
<dl className="sp-diag-list">
<div className="sp-diag-row">
<dt className="sp-diag-key">Runtime status</dt>
<dd className="sp-diag-val">{snapshot.status}</dd>
</div>
<div className="sp-diag-row">
<dt className="sp-diag-key">Binary path</dt>
<dd className="sp-diag-val sp-mono">
{snapshot.binary_path || "Not found"}
</dd>
</div>
{snapshot.resolved_url && (
<div className="sp-diag-row">
<dt className="sp-diag-key">Resolved endpoint</dt>
<dd className="sp-diag-val sp-accent">{snapshot.resolved_url}</dd>
</div>
)}
{snapshot.last_error && (
<div className="sp-diag-row">
<dt className="sp-diag-key">Last error</dt>
<dd className="sp-diag-val sp-danger">{snapshot.last_error.message}</dd>
</div>
)}
</dl>
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className="settings-action-bar">
<div className="action-bar-inner">
<button
type="button"
className="sp-btn sp-btn--ghost btn-discard"
onClick={resetDraft}
disabled={busyAll || !draftDirty}
>
Discard
</button>
<button
type="button"
className="sp-btn sp-btn--secondary btn-save"
onClick={() => void handleSave()}
disabled={busyAll || !draftDirty}
>
{saving ? "Saving…" : "Save"}
</button>
{isLocal && !isRunning && (
<button
type="button"
className="sp-btn sp-btn--primary btn-save"
onClick={() => void handleSaveAndStart()}
disabled={busyAll || !Boolean(draftConfig.model_path)}
>
{saving ? "Starting…" : "Save & Start"}
</button>
)}
{isLocal && isRunning && (
<button
type="button"
className="sp-btn sp-btn--danger btn-stop"
onClick={() => void handleStop()}
disabled={busyAll || isStopping}
>
{isStopping ? "Stopping…" : "Stop runtime"}
</button>
)}
</div>
</div>
</div>
</div>
);
}
+3
View File
@@ -54,3 +54,6 @@ html, body, #root {
/* ── Highlight.js theme override ── */
.hljs { background: var(--code-bg) !important; }
/* ── Tailwind utilities (preflight disabled — existing styles preserved) ── */
@tailwind utilities;
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
corePlugins: { preflight: false }, // preserve existing hand-crafted CSS
theme: {
extend: {
colors: {
"c-bg": "var(--bg)",
"c-bg2": "var(--bg2)",
"c-bg3": "var(--bg3)",
"c-bg4": "var(--bg4)",
"c-bg5": "var(--bg5)",
"c-bdr": "var(--border)",
"c-bdr2": "var(--border2)",
"c-tx": "var(--text)",
"c-tx2": "var(--text2)",
"c-tx3": "var(--text3)",
"c-ac": "var(--accent)",
"c-ac2": "var(--accent2)",
"c-danger": "var(--danger)",
"c-success": "var(--success)",
"c-warn": "var(--warning)",
},
},
},
};