Adds OpenAI support

Refactors the dispatch logic to be more engine-agnostic.

Engines are a backend to an LLM provider, like Ollama, OpenAI, etc. Each
Engine has a Client that is responsible for actually talking to the
backend.

Models now have an `id` in the format of `<engine>:<model-name>`. So for
example, `ollama:qwen3:14b`.

Models, in the model dropdown in chat, are now grouped and organized by
Engine. So there's an Ollama section, an OpenAI section, etc.
This commit is contained in:
Matte Noble
2025-05-16 10:12:40 -07:00
parent 4ad87fa7ee
commit a7cc14c240
33 changed files with 757 additions and 534 deletions

View File

@@ -59,6 +59,8 @@
"change-case": "^5.4.4",
"marked": "^15.0.7",
"moment": "^2.30.1",
"ollama": "^0.5.15",
"openai": "^4.98.0",
"tailwind-merge": "^3.0.2"
}
}

244
pnpm-lock.yaml generated
View File

@@ -35,6 +35,12 @@ importers:
moment:
specifier: ^2.30.1
version: 2.30.1
ollama:
specifier: ^0.5.15
version: 0.5.15
openai:
specifier: ^4.98.0
version: 4.98.0
tailwind-merge:
specifier: ^3.0.2
version: 3.0.2
@@ -47,16 +53,16 @@ importers:
version: 9.21.0
'@sveltejs/adapter-auto':
specifier: ^4.0.0
version: 4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))
version: 4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.8(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))
version: 3.0.8(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))
'@sveltejs/kit':
specifier: ^2.16.0
version: 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
version: 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.0
version: 5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
version: 5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@tailwindcss/postcss':
specifier: ^4.0.12
version: 4.0.12
@@ -65,7 +71,7 @@ importers:
version: 0.5.16(tailwindcss@4.0.12)
'@tailwindcss/vite':
specifier: ^4.0.9
version: 4.0.9(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
version: 4.0.9(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@tauri-apps/cli':
specifier: ^2.3.1
version: 2.3.1
@@ -125,7 +131,7 @@ importers:
version: 8.26.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
vite:
specifier: ^6.0.0
version: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
version: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
packages:
@@ -772,6 +778,12 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node@18.19.100':
resolution: {integrity: sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA==}
'@types/uuid4@2.0.3':
resolution: {integrity: sha512-/fyn8jzKzeL/wci7GOaz8TPjKapD+WJUBUCr/ED2xcUcx5fA9rZH2fDZiV2Z/a+040mp5Zi3dgIi/Vey/uQBxw==}
@@ -822,6 +834,10 @@ packages:
resolution: {integrity: sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -837,6 +853,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -879,6 +899,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -938,6 +961,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -998,6 +1025,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
@@ -1164,6 +1195,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1211,6 +1246,17 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1295,6 +1341,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1561,6 +1610,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1593,6 +1650,20 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -1617,6 +1688,21 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
ollama@0.5.15:
resolution: {integrity: sha512-TSaZSJyP7MQJFjSmmNsoJiriwa3U+/UJRw6+M8aucs5dTsaWNZsBIGpDb5rXnW6nXxJBB/z79gZY8IaiIQgelQ==}
openai@4.98.0:
resolution: {integrity: sha512-TmDKur1WjxxMPQAtLG5sgBSCJmX7ynTsGmewKzoDwl1fRxtbLOsiR0FA/AOAAtYUmP6azal+MYQuOENfdU+7yg==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1951,6 +2037,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ts-api-utils@2.0.1:
resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==}
engines: {node: '>=18.12'}
@@ -1996,6 +2085,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2050,6 +2142,19 @@ packages:
vite:
optional: true
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2324,18 +2429,18 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))':
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))':
dependencies:
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
import-meta-resolve: 4.1.0
'@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))':
'@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))':
dependencies:
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
'@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
dependencies:
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@@ -2348,27 +2453,27 @@ snapshots:
set-cookie-parser: 2.7.1
sirv: 3.0.1
svelte: 5.21.0
vite: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vite: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
dependencies:
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
debug: 4.4.0
svelte: 5.21.0
vite: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vite: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.21.0)(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
debug: 4.4.0
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 5.21.0
vite: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vitefu: 1.0.6(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
vite: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vitefu: 1.0.6(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))
transitivePeerDependencies:
- supports-color
@@ -2495,13 +2600,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.0.12
'@tailwindcss/vite@4.0.9(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
'@tailwindcss/vite@4.0.9(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))':
dependencies:
'@tailwindcss/node': 4.0.9
'@tailwindcss/oxide': 4.0.9
lightningcss: 1.29.1
tailwindcss: 4.0.9
vite: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vite: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
'@tauri-apps/api@2.3.0': {}
@@ -2580,6 +2685,15 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/node-fetch@2.6.12':
dependencies:
'@types/node': 18.19.100
form-data: 4.0.2
'@types/node@18.19.100':
dependencies:
undici-types: 5.26.5
'@types/uuid4@2.0.3': {}
'@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
@@ -2659,6 +2773,10 @@ snapshots:
'@typescript-eslint/types': 8.26.0
eslint-visitor-keys: 4.2.0
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-jsx@5.3.2(acorn@8.14.0):
dependencies:
acorn: 8.14.0
@@ -2669,6 +2787,10 @@ snapshots:
acorn@8.14.0: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -2734,6 +2856,8 @@ snapshots:
async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -2793,6 +2917,10 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {}
cookie@0.6.0: {}
@@ -2847,6 +2975,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delayed-stream@1.0.0: {}
detect-libc@1.0.3: {}
devalue@5.1.1: {}
@@ -3127,6 +3257,8 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -3173,6 +3305,20 @@ snapshots:
dependencies:
is-callable: 1.2.7
form-data-encoder@1.7.2: {}
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
fsevents@2.3.3:
optional: true
@@ -3258,6 +3404,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
ignore@5.3.2: {}
import-fresh@3.3.1:
@@ -3493,6 +3643,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.11
@@ -3515,6 +3671,12 @@ snapshots:
natural-compare@1.4.0: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -3548,6 +3710,22 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
ollama@0.5.15:
dependencies:
whatwg-fetch: 3.6.20
openai@4.98.0:
dependencies:
'@types/node': 18.19.100
'@types/node-fetch': 2.6.12
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3881,6 +4059,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
ts-api-utils@2.0.1(typescript@5.8.2):
dependencies:
typescript: 5.8.2
@@ -3948,26 +4128,40 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0):
vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0):
dependencies:
esbuild: 0.25.0
postcss: 8.5.3
rollup: 4.34.9
optionalDependencies:
'@types/node': 18.19.100
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.29.1
yaml: 2.7.0
vitefu@1.0.6(vite@6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)):
vitefu@1.0.6(vite@6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)):
optionalDependencies:
vite: 6.2.0(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
vite: 6.2.0(@types/node@18.19.100)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:

View File

@@ -59,7 +59,7 @@ log "chmod +x hermit"
chmod +x ~/.config/runebook/hermit/bin/hermit
log "hermit install node >> "$LOGFILE""
hermit install node >> "$LOGFILE"
hermit install node@lts >> "$LOGFILE"
log "npx $*"
npx "$@" || log "Error running npx $*"

View File

@@ -7,16 +7,16 @@
import Message from '$components/Message.svelte';
import { dispatch } from '$lib/dispatch';
import type { IMessage } from '$lib/models/message';
import Model from '$lib/models/model.svelte';
import type { IModel } from '$lib/models/model';
import Session, { type ISession } from '$lib/models/session';
interface Props {
session: ISession;
model: string;
model: IModel;
onMessages?: (message: IMessage[]) => Promise<void>;
}
const { session, model }: Props = $props();
const { session, model = $bindable() }: Props = $props();
// DOM elements used via `bind:this`
let input: HTMLTextAreaElement;
@@ -91,7 +91,7 @@
<Flex class="h-content w-full flex-col p-8 pr-2 pb-0">
<Scrollable bind:ref={content} class="mb-8">
<!-- Chat Log -->
<div class:opacity-25={!Model.exists(model)} class="bg-medium relative h-full w-full px-2">
<div class:opacity-25={false} class="bg-medium relative h-full w-full px-2">
{#each messages as message (message.id)}
<Flex id="messages" class="w-full flex-col items-start">
<!-- Svelte hack: ensure chat is always scrolled to the bottom when a new message is added -->
@@ -116,7 +116,7 @@
bind:this={input}
oninput={resize}
onkeydown={onChatInput}
disabled={!Model.exists(model)}
disabled={false}
placeholder="Message..."
class="disabled:text-dark item bg-dark border-light focus:border-purple/15 mb-8
h-auto w-[calc(100%-calc(var(--spacing)*6))] grow self-start rounded-xl border

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import Flex from '$components/Flex.svelte';
import type { IEngine } from '$lib/models/engine';
import Model, { type IModel } from '$lib/models/model';
interface Props {
engines: IEngine[];
value: string;
onselect?: (model: IModel) => Promise<void>;
}
let { engines, onselect, value = $bindable() }: Props = $props();
let isOpen = $state(false);
const model = $derived(Model.find(value));
async function select(m: IModel) {
close();
value = m.name;
await onselect?.(m);
}
function toggle(e: Event) {
e.stopPropagation();
isOpen = isOpen ? false : true;
}
function close() {
isOpen = false;
}
</script>
<Flex class="bg-medium relative h-16 w-full hover:cursor-pointer">
<Flex
onclick={(e) => toggle(e)}
class="border-light absolute top-0 left-0 w-full justify-between
rounded-md border p-2 px-4"
>
<p>{model?.name}</p>
<p></p>
</Flex>
{#if isOpen}
<Flex
class="border-light bg-medium absolute top-12 left-0 z-50 -mt-[1px]
max-h-[calc(100vh*0.7)] w-full flex-col items-start overflow-y-auto rounded-md
rounded-t-none border"
>
{#each engines as engine (engine.id)}
<p class="text-medium px-4 pt-4 pb-2 text-sm font-[500] uppercase">
{engine.name}
</p>
<div>
{#each engine.models as model (model.id)}
<button
onclick={async () => await select(model)}
class="border-light w-full border-b p-2 px-4 pl-8 text-left
first:border-t last:border-b-0 hover:cursor-pointer"
>
{model.name}
</button>
{/each}
</div>
{/each}
</Flex>
{/if}
</Flex>

View File

@@ -6,8 +6,13 @@
import Flex from '$components/Flex.svelte';
import closables from '$lib/closables';
interface Option {
display: string;
value: string;
}
interface Props extends HTMLAttributes<HTMLDivElement> {
options: string[];
options: Option[];
value?: string;
onSelect: () => Promise<void>;
}
@@ -55,10 +60,10 @@
>
{#each options as option (option)}
<button
onclick={async () => await select(option)}
onclick={async () => await select(option.value)}
class="bg-dark border-b-light w-full border-b p-2 px-4 text-left last:border-b-0 hover:cursor-pointer"
>
{option}
{option.display}
</button>
{/each}
</Flex>

View File

@@ -6,12 +6,11 @@ import { goto } from '$app/navigation';
import { WELCOME_AGREED } from "$lib/const";
import Config from "$lib/config";
import { setupDeeplinks } from '$lib/deeplinks';
import { OllamaClient } from '$lib/llm';
import { info } from '$lib/logger';
import App from "$lib/models/app";
import Engine from '$lib/models/engine';
import McpServer from "$lib/models/mcp-server";
import Message from "$lib/models/message";
import Model from '$lib/models/model.svelte';
import Session from "$lib/models/session";
import Setting from "$lib/models/setting";
import startup, { StartupCheck } from "$lib/startup";
@@ -29,21 +28,9 @@ export const init: ClientInit = async () => {
await Message.sync();
await McpServer.sync();
await Setting.sync();
await Engine.sync();
info('[green]✔ database synced');
const client = new OllamaClient();
await startup.addCheck(
StartupCheck.Ollama,
async () => await client.connected(),
);
await startup.addCheck(
StartupCheck.MissingModels,
async () => await client.hasModels(),
async () => await Model.sync(),
);
await startup.addCheck(
StartupCheck.Agreement,
async () => await Config.get(WELCOME_AGREED) === true,

View File

@@ -1,6 +0,0 @@
export interface VSCodeMcpInstallConfig {
name: string;
type: string;
command: string;
args: string[];
}

View File

@@ -1,7 +1,12 @@
import { listen } from "@tauri-apps/api/event";
import { goto } from "$app/navigation";
export * from '$lib/deeplinks.d';
export interface VSCodeMcpInstallConfig {
name: string;
type: string;
command: string;
args: string[];
}
export enum DeepLinks {
InstallMcpServer = 'mcp/install',

View File

@@ -1,17 +1,25 @@
import { invoke } from "@tauri-apps/api/core";
import { type LlmOptions, OllamaClient } from "./llm";
import type { Options } from "$lib/engines/types";
import { error } from "$lib/logger";
import App from "$lib/models/app";
import Engine from "$lib/models/engine";
import Message, { type IMessage } from "$lib/models/message";
import type { IModel } from "$lib/models/model";
import Session, { type ISession } from "$lib/models/session";
export async function dispatch(session: ISession, model: string, prompt?: string): Promise<IMessage> {
export async function dispatch(session: ISession, model: IModel, prompt?: string): Promise<IMessage> {
const app = App.find(session.appId as number);
const client = new OllamaClient();
const engine = Engine.fromModelId(model.id);
if (!engine) {
error(`MissingEngineError`, model.id);
throw `MissingEngineError: ${model.id}`;
}
if (!app) {
throw "Missing app";
error(`MissingAppError`, session.appId);
throw `MissingAppError: ${session.appId}`;
}
if (prompt) {
@@ -28,12 +36,12 @@ export async function dispatch(session: ISession, model: string, prompt?: string
tool_calls: m.toolCalls,
}));
const options: LlmOptions = {
const options: Options = {
num_ctx: session.config.contextWindow,
temperature: session.config.temperature,
};
const message = await client.chat(
const message = await engine.client.chat(
model,
messages,
await Session.tools(session),

84
src/lib/engines/ollama.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Ollama as OllamaClient } from 'ollama/browser';
import type { Client, Message, Options, Tool } from '$lib/engines/types';
import type { IModel } from '$lib/models';
import type { IMessage } from "$lib/models/message";
import Setting from "$lib/models/setting";
export default class Ollama implements Client {
client: OllamaClient;
constructor(host: string) {
this.client = new OllamaClient({ host });
}
async chat(model: IModel, messages: Message[], tools: Tool[] = [], options: Options = {}): Promise<IMessage> {
const response = await this.client.chat({
model: model.name,
messages,
tools,
options,
stream: false,
});
let thought: string | undefined;
let content: string = response
.message
.content
.replace(/\.$/, '')
.replace(/^"/, '')
.replace(/"$/, '');
if (content.includes('<think>')) {
[thought, content] = content.split('</think>');
thought = thought.replace('<think>', '').trim();
content = content.trim();
}
return {
model: model.name,
role: 'assistant',
content,
thought,
name: '',
toolCalls: response.message.tool_calls || [],
};
}
async models(): Promise<IModel[]> {
const models = (
await this.client.list()
).models;
return Promise.all(
models.map(async (model) => (
await this.info(model.name)
))
);
}
async info(name: string): Promise<IModel> {
const metadata = await this.client.show({ model: name });
// @ts-expect-error The Ollama SDK doesn't define the `capabilities`
// property, but the API returns it.
const capabilities = metadata.capabilities as string[];
return {
id: `ollama:${name}`,
name,
metadata,
supportsTools: capabilities.includes('tools'),
};
}
async connected(): Promise<boolean> {
if (!Setting.OllamaUrl) {
return false;
}
return (
await fetch(Setting.OllamaUrl)
).status == 200;
}
}

96
src/lib/engines/openai.ts Normal file
View File

@@ -0,0 +1,96 @@
import { OpenAI as OpenAIClient } from 'openai';
import type { Client, Message, Options, Tool, ToolCall } from '$lib/engines/types';
import type { IMessage, IModel } from "$lib/models";
const SUPPORTED_MODELS = [
'gpt-4o',
'o4-mini',
'gpt-4.5-preview',
'gpt-4.1',
'gpt-4.1-mini',
];
export default class OpenAI implements Client {
client: OpenAIClient;
constructor(apiKey: string) {
this.client = new OpenAIClient({
apiKey,
dangerouslyAllowBrowser: true,
});
}
async chat(model: IModel, messages: Message[], tools: Tool[] = [], options: Options = {}): Promise<IMessage> {
const response = await this.client.chat.completions.create({
model: model.name,
// @ts-expect-error For some reason, the type of `messages` is marked
// incorrect because of a difference in the `role`. Our `Role` type is
// ripped straight from the OpenAI code, though, so :shrug:
messages,
tools,
temperature: options.temperature,
});
const {
role,
content,
tool_calls,
} = response.choices[0].message;
let toolCalls: ToolCall[] = [];
if (tool_calls) {
toolCalls = tool_calls.map(tc => ({
function: {
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments),
}
}));
}
return {
model: model.name,
name: '',
role,
content: content || '',
toolCalls,
};
}
async models(): Promise<IModel[]> {
return (
await this.client.models.list()
)
.data
.filter(model => SUPPORTED_MODELS.includes(model.id))
.map(model => {
const { id, ...metadata } = model;
return {
id: `openai:${id}`,
name: id,
metadata,
supportsTools: true,
};
});
}
async info(model: string): Promise<IModel> {
const { id, ...metadata } = (
await this.client.models.retrieve(model)
);
return {
id: `openai:${id}`,
name: id,
metadata,
supportsTools: true,
};
}
async connected(): Promise<boolean> {
return (
await fetch('https://api.openai.com')
).status == 200;
}
}

55
src/lib/engines/types.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { IMessage, IModel } from "$lib/models";
export interface Client {
chat(model: IModel, messages: Message[], tools?: Tool[], options?: Options): Promise<IMessage>;
models(): Promise<IModel[]>;
info(model: string): Promise<IModel>;
connected(): Promise<boolean>;
}
export interface Options {
num_ctx?: number;
temperature?: number;
}
export type Role =
| 'developer'
| 'system'
| 'user'
| 'assistant'
| 'tool'
| 'function';
export interface Message {
role: Role;
content: string;
name: string;
tool_calls?: ToolCall[];
}
export interface ToolCall {
function: {
name: string;
arguments: {
[key: string]: any; // eslint-disable-line
}
}
}
export interface Tool {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: string;
required: string[];
properties: Record<string, Property>;
}
}
}
interface Property {
type: string;
description: string;
}

111
src/lib/llm.d.ts vendored
View File

@@ -1,111 +0,0 @@
export interface OllamaRequest {
model: string;
stream: boolean;
tools: Tool[];
options: Options;
messages: Message[];
}
export interface OllamaResponse {
created_at: string;
done: boolean;
done_reason: string;
eval_count: number;
eval_duration: number;
load_duration: number;
message: {
content: string;
role: LlmMessageRole;
tool_calls: ToolCall[];
};
model: string;
prompt_eval_count: number;
prompt_eval_duration: number;
total_duration: number;
}
export interface LlmOptions {
num_ctx?: number;
temperature?: number;
}
export interface OllamaModel {
name: string;
modifiedAt: string;
size: number;
digest: string;
details: {
parentModel: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
};
model_info: {
"general.architecture": string;
"general.file_type": number;
"general.parameter_count": number;
"general.quantization_version": number;
"llama.attention.head_count": number;
"llama.attention.head_count_kv": number;
"llama.attention.layer_norm_rms_epsilon": number;
"llama.block_count": number;
"llama.context_length": number;
"llama.embedding_length": number;
"llama.feed_forward_length": number;
"llama.rope.dimension_count": number;
"llama.rope.freq_base": number;
"llama.vocab_size": number;
"tokenizer.ggml.bos_token_id": number;
"tokenizer.ggml.eos_token_id": number;
"tokenizer.ggml.merges": string[];
"tokenizer.ggml.model": string;
"tokenizer.ggml.pre": string;
"tokenizer.ggml.token_type": string[];
"tokenizer.ggml.tokens": string[];
};
capabilities?: string[];
}
export type OllamaTag = Pick<OllamaModel, 'name' | 'modifiedAt' | 'size' | 'digest' | 'details'>;
export type LlmMessageRole = 'system' | 'tool' | 'user' | 'assistant';
export interface OllamaTags {
models: Tag[];
}
export interface LlmMessage {
role: LlmMessageRole;
content: string;
name: string;
tool_calls?: Record<string, any>; // eslint-disable-line
}
export interface LlmToolCall {
function: {
name: string;
// We have no way of knowing what the LLM will pass
// eslint-disable-next-line
arguments: Record<string, any>;
}
}
export interface LlmTool {
type: string;
function: {
name: string;
description: string;
parameters: {
type: string;
required: string[];
properties: Record<string, Property>;
}
}
}
interface LlmProperty {
type: string;
description: string;
}

View File

@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type { LlmTool } from "$lib/llm";
import type { Tool } from "$lib/engines/types";
import type { McpTool } from "$lib/mcp.d";
import type { ISession } from "$lib/models/session";
@@ -9,7 +9,7 @@ export * from '$lib/mcp.d';
// Retrieve, and transform, tools from the MCP server, into `tools` object we
// can send to the LLM.
//
export async function getMCPTools(session: ISession): Promise<LlmTool[]> {
export async function getMCPTools(session: ISession): Promise<Tool[]> {
return (
await invoke<McpTool[]>('get_mcp_tools', { sessionId: session.id })
).map(tool => {

View File

@@ -181,7 +181,7 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
/**
* Find the first occurence by a subset of the model's properties.
*/
static findBy(params: Partial<Interface>): Interface {
static findBy(params: Partial<Interface>): Interface | undefined {
return this.where(params)[0];
}
@@ -490,3 +490,40 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
}
}
}
/**
* Model class NOT backed by a database
*/
export function BareModel<T extends Obj>() {
let repo: T[] = $state([]);
return class BareModel {
static reset(instances: T[] = []) {
repo = instances;
}
static add(instance: T) {
repo.push(instance);
}
static delete(instance: T) {
repo = repo.filter(i => i !== instance);
}
static all(): T[] {
return repo;
}
static find(id: string): T | undefined {
return repo.findBy('id', id);
}
static first(): T {
return repo[0];
}
static last(): T {
return repo[repo.length - 1];
}
}
}

49
src/lib/models/engine.ts Normal file
View File

@@ -0,0 +1,49 @@
import Ollama from '$lib/engines/ollama';
import OpenAI from '$lib/engines/openai'
import type { Client } from '$lib/engines/types';
import { Setting } from '$lib/models';
import { BareModel } from '$lib/models/base.svelte';
import Model, { type IModel } from '$lib/models/model';
export interface IEngine {
id: string;
name: string;
client: Client;
models: IModel[];
}
export default class Engine extends BareModel<IEngine>() {
static fromModelId(id: string): IEngine | undefined {
return this.find(id.split(':')[0]);
}
static async sync() {
this.reset();
if (Setting.OllamaUrl) {
const client = new Ollama(Setting.OllamaUrl);
this.add({
id: 'ollama',
name: 'Ollama',
client,
models: (await client.models()).sortBy('name'),
})
}
if (Setting.OpenAIKey) {
const client = new OpenAI(Setting.OpenAIKey);
this.add({
id: 'openai',
name: 'OpenAI',
client,
models: (await client.models()).sortBy('name'),
});
}
Model.reset(
this.all().flatMap(engine => engine.models)
);
}
}

View File

@@ -1,15 +1,16 @@
import { info } from '$lib/logger';
import App from '$lib/models/app';
import Engine from '$lib/models/engine';
import McpServer from '$lib/models/mcp-server';
import Message from '$lib/models/message';
import Model from '$lib/models/model.svelte';
import Session from '$lib/models/session';
import Setting from '$lib/models/setting';
export { default as App, type IApp } from '$lib/models/app';
export { default as Engine, type IEngine } from '$lib/models/engine';
export { type IMcpServer, default as McpServer } from '$lib/models/mcp-server';
export { type IMessage, default as Message } from '$lib/models/message';
export { type IModel, default as Model } from '$lib/models/model.svelte';
export { type IModel, default as Model } from '$lib/models/model';
export { type ISession, default as Session } from '$lib/models/session';
export { type ISetting, default as Setting } from '$lib/models/setting';
@@ -19,6 +20,6 @@ export async function resync() {
await Message.sync();
await McpServer.sync();
await Setting.sync();
await Model.sync();
await Engine.sync();
info('[green]✔ resynced');
}

View File

@@ -35,7 +35,7 @@ interface Metadata {
export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
static defaults = {
name: 'Unknown',
name: 'Installing...',
command: '',
metadata: {
protocolVersion: '',

View File

@@ -1,17 +1,17 @@
import moment from "moment";
import type { LlmMessageRole } from '$lib/llm.d';
import type { Role, ToolCall } from "$lib/engines/types";
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import Session, { type ISession } from "$lib/models/session";
export interface IMessage {
id?: number;
role: LlmMessageRole;
role: Role;
content: string;
thought?: string;
model: string;
name: string;
toolCalls: Record<string, any>[]; // eslint-disable-line
toolCalls: ToolCall[];
sessionId?: number;
responseId?: number;
created?: moment.Moment;
@@ -54,7 +54,7 @@ export default class Message extends Model<IMessage, Row>('messages') {
protected static async fromSql(row: Row): Promise<IMessage> {
return {
id: row.id,
role: row.role as LlmMessageRole,
role: row.role as Role,
content: row.content,
thought: row.thought,
model: row.model,

View File

@@ -1,56 +0,0 @@
import { OllamaClient, type OllamaModel } from "$lib/llm";
export type IModel = OllamaModel;
export interface Details {
parentModel: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
}
let repo: IModel[] = $state([]);
export default class Model {
static async sync(): Promise<void> {
const client = new OllamaClient();
const models: IModel[] = await client.list();
repo = await Promise.all(
models.map(async m => {
const model = await client.info(m.name);
return { ...m, ...model }
})
);
}
static default(): IModel {
return repo[0];
}
static find(name: string): IModel {
return repo.find(m => m.name == name) as IModel;
}
static exists(name: string): boolean {
return this.find(name) !== undefined;
}
static first(): IModel {
return repo[0];
}
static last(): IModel {
return repo[repo.length - 1];
}
static all(): IModel[] {
return repo;
}
static supportsTools(model: IModel): boolean {
return model && model.capabilities?.includes('tools') == true;
}
}

21
src/lib/models/model.ts Normal file
View File

@@ -0,0 +1,21 @@
import { BareModel } from "$lib/models/base.svelte";
import Engine from "$lib/models/engine";
export interface IModel {
id: string;
name: string;
supportsTools: boolean;
metadata: {
[key: string]: any; // eslint-disable-line
}
}
export default class Model extends BareModel<IModel>() {
static default(): IModel {
return Engine.first().models[0];
}
static findOrDefault(id: string): IModel {
return this.find(id) || this.default();
}
}

View File

@@ -1,12 +1,14 @@
import moment from "moment";
import { type LlmTool, OllamaClient } from "$lib/llm";
import Engine from "./engine";
import type { Tool } from "$lib/engines/types";
import { getMCPTools } from '$lib/mcp';
import App, { type IApp } from '$lib/models/app';
import Base, { type ToSqlRow } from '$lib/models/base.svelte';
import { type IMcpServer } from "$lib/models/mcp-server";
import type { IMcpServer } from "$lib/models/mcp-server";
import Message, { type IMessage } from "$lib/models/message";
import Model from '$lib/models/model.svelte';
import Model from '$lib/models/model';
export const DEFAULT_SUMMARY = 'Untitled';
export interface ISession {
@@ -36,7 +38,7 @@ export default class Session extends Base<ISession, Row>('sessions') {
static defaults = () => ({
summary: DEFAULT_SUMMARY,
config: {
model: Model.default().name,
model: Model.default().id,
contextWindow: 4096,
temperature: 0.8,
enabledMcpServers: [],
@@ -53,10 +55,8 @@ export default class Session extends Base<ISession, Row>('sessions') {
return Message.where({ sessionId: session.id });
}
static async tools(session: ISession): Promise<LlmTool[]> {
const model = Model.find(session.config.model);
if (!Model.supportsTools(model)) {
static async tools(session: ISession): Promise<Tool[]> {
if (!Model.find(session.config.model)?.supportsTools) {
return [];
}
@@ -92,7 +92,7 @@ export default class Session extends Base<ISession, Row>('sessions') {
return await this.update(session);
}
static async summarize(session: ISession, model: string) {
static async summarize(session: ISession, modelId: string) {
if (!session.id) {
return;
}
@@ -101,9 +101,15 @@ export default class Session extends Base<ISession, Row>('sessions') {
return;
}
const client = new OllamaClient();
const message: IMessage = await client.chat(
model,
const engine = Engine.fromModelId(modelId);
const model = Model.find(modelId);
if (!engine || !model) {
return;
}
const message: IMessage = await engine.client.chat(
model.name,
[
...this.messages(session),
{

View File

@@ -1,6 +1,9 @@
import { OllamaClient } from '$lib/llm';
import Ollama from '$lib/engines/ollama';
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import LLMModel from '$lib/models/model.svelte';
import Engine from '$lib/models/engine';
// OpenAI API Key
const OPENAI_API_KEY = 'openai-api-key';
// Ollama URL
export const OLLAMA_URL_CONFIG_KEY = 'ollama-url';
@@ -20,23 +23,25 @@ interface Row {
}
export default class Setting extends Model<ISetting, Row>('settings') {
static get OllamaUrl(): string {
return this.findBy({ key: OLLAMA_URL_CONFIG_KEY }).value as string;
static get OllamaUrl(): string | undefined {
return this.findBy({ key: OLLAMA_URL_CONFIG_KEY })?.value as string;
}
static get OpenAIKey(): string | undefined {
return this.findBy({ key: OPENAI_API_KEY })?.value as string;
}
static async validate(setting: ISetting): Promise<boolean> {
if (setting.key == OLLAMA_URL_CONFIG_KEY) {
const client = new OllamaClient({ url: setting.value as string })
const client = new Ollama(setting.value as string);
return await client.connected();
}
return true;
}
protected static async afterUpdate(setting: ISetting): Promise<ISetting> {
if (setting.key == OLLAMA_URL_CONFIG_KEY) {
await LLMModel.sync();
}
// Resync models in case a Provider key/url was updated.
await Engine.sync();
return setting;
}

View File

@@ -1 +0,0 @@
export * from '$lib/smithery.d';

View File

@@ -1,7 +1,5 @@
import { HttpClient } from "$lib/http";
import type { CompactServer, Server, ServerList } from '$lib/smithery.d';
export * from '$lib/smithery.d';
import type { CompactServer, Server, ServerList } from '$lib/smithery/types';
export class Client extends HttpClient {
options: RequestInit = {

View File

@@ -1,227 +0,0 @@
import ts from 'typescript';
import type { Server } from '$lib/smithery';
const IS_CONFIG = /^(\s*config\.)(.+)$/;
// A Node is an object that knows how to be evaluated using a `config` object
// which may contain values to be interpolated.
//
abstract class Node {
text: string;
abstract eval(config: Record<string, string>): string;
constructor(text: string) {
this.text = text.trim().replaceAll("'", '');
}
}
// No interpolation, just a regular string.
//
// `text` is the value.
//
class StringNode extends Node {
eval(_: Record<string, string>) {
return this.text;
}
}
// Gets its value from the config object passed in.
//
// `text` is the name of the key in `config`.
//
class ConfigNode extends Node {
eval(config: Record<string, string>): string {
return config[this.text];
}
}
// Parent object that wraps a sets of `Node`s and can give us the final
// `Config` using those `Node`s.
//
export class Config {
command: StringNode;
args: Node[];
env: Record<string, Node>;
static from(server: Server) {
return parse(server);
}
constructor() {
this.command = new StringNode('');
this.args = [];
this.env = {};
}
eval(config: Record<string, string>) {
return {
command: this.command.eval(config),
args: this.args.map(a => a.eval(config)),
env: Object.fromEntries(
Object.entries(this.env).map(([k, v]) => [k, v.eval(config)])
),
}
}
}
// Parse out the start command of an MCP server, from Smithery's configuration
// file syntax.
//
// Smithery's JSON schema for MCP servers involves specifying a JS function
// that evaluates into the final config object used to start the server
// process.
//
// ```
// startCommand:
// ...
// commandFunction:
// |-
// (config) => ({
// "command" => "npx",
// "args" => ["an-mcp-server", "--apiKey", config.apiKey],
// "env": {
// OTHER_THING: config.otherThing
// }
// })
// ```
//
// This seems like a supremely bad idea, since it means `eval`ing arbitrary,
// user-supplied, code. Likely on a server, since it's the parts necessary to
// spawn a process. I could totally be missing something, though. so :shrug:
//
// In any case, we're not going to `eval` anything. Instead, we build an AST of
// this code and parse out the nodes we care about. We build a `Config` object
// that will then be "evaluated" later, when we have the values we need to
// interpolate.
//
// This approach avoids any dangers of executing arbitrary code.
//
function parse(server: Server): Config {
const stdio = server.connections.find(c => c.type == 'stdio');
const cmd = stdio?.stdioFunction || '';
const config: Config = new Config();
// Build an AST from the function declaration
const source = ts.createSourceFile(
"source.ts",
cmd,
ts.ScriptTarget.Latest,
true,
);
// Relevant tokens to eval
const tokens: string[] = [];
// Take the next token unconditionally.
let take = false;
// Collect the tokens we care about from the AST
visit(source);
// For each token, transform them into `Node`s.
tokens.forEach((token, i) => {
if (token === 'command') {
config.command = command(tokens[i + 1]);
} else if (token === 'args') {
config.args = args(tokens[i + 1]);
} else if (token === 'env') {
config.env = env(tokens[i + 1]);
}
});
return config;
// Visit a Typescript AST node and track relevant ones.
//
// We only care about two types of nodes `Identifier` (that has a text
// value of "command", "args", or "env") and next node.
//
function visit(node: ts.Node) {
// Previous token was an identifier that we care about. If that's the
// case, also track this node as it's the value of one of those objects
if (take) {
tokens.push(node.getText());
}
// Reset this so we only record N(Identifier)+1 nodes.
take = false;
// If we care about this, track it.
if (node.kind == ts.SyntaxKind.Identifier && isNeeded(node.getText())) {
tokens.push(node.getText());
take = true;
}
// Keep on truck'n.
ts.forEachChild(node, (c) => visit(c));
}
// Command is always just a plain string.
//
function command(text: string) {
return new StringNode(text);
}
// Parse the list of `args` into a list of `Node`s.
//
// An `arg` can either just be a plain string "--verbose" which will end
// up as a `StringNode`.
//
// Or it can be a `config` variable "config.apiKey" which will end up
// as a `ConfigNode` and be evaluated later for the final value.
//
function args(text: string): Node[] {
text = text.replace('[', '').replace(']', '');
if (text == '') {
return [];
}
return text
.split(',')
.map(a => (
IS_CONFIG.test(a)
? new ConfigNode(a.trim().replace('config.', ''))
: new StringNode(a)
))
.compact() as Node[];
}
// Parse the list of `env` vars into a list of `Node`s.
//
// `env` entries will always have a plain string key, but can have a string
// or config value.
//
// Does the same as `args` and tracks the corresponding `Node` to be
// evaluated later.
//
function env(text: string): Record<string, Node> {
text = text.replace('{', '').replace('}', '');
if (text === '') {
return {};
}
return Object.fromEntries(
text
.split(',')
.map(kv => {
let [k, v] = kv.split(':'); // eslint-disable-line
let value: Node = new StringNode(v);
if (IS_CONFIG.test(v)) {
value = new ConfigNode(v.replace('config.', ''));
}
return [k, value];
})
);
}
// We only care about these three keys
//
function isNeeded(text: string) {
return ['command', 'args', 'env'].includes(text);
}
}

View File

@@ -1,5 +1,4 @@
import jsonSchema from 'json-schema';
const { JSONSchema7 } = jsonSchema;
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface ServerList {
servers: CompactServer[];
@@ -52,7 +51,7 @@ export interface ConfigSchema {
properties: {
[key: string]: {
type: 'string';
default?: any; // eslint-disable-line
default?: any;
description: string;
};
};
@@ -65,3 +64,4 @@ export interface Config {
value: string;
valid: boolean;
}

View File

@@ -10,25 +10,26 @@
import Layout from '$components/Layouts/Default.svelte';
import Link from '$components/Link.svelte';
import Menu, { type MenuItem } from '$components/Menu.svelte';
import Select from '$components/Select.svelte';
import ModelMenu from '$components/ModelMenu.svelte';
import Svg from '$components/Svg.svelte';
import Toggle from '$components/Toggle.svelte';
import Engine, { type IEngine } from '$lib/models/engine';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import Message from '$lib/models/message';
import Model, { type IModel } from '$lib/models/model.svelte';
import Model, { type IModel } from '$lib/models/model';
import Session, { type ISession } from '$lib/models/session';
const session: ISession = $derived(Session.find(page.params.session_id));
const model: IModel = $derived(Model.find(session.config.model));
const model: IModel = $derived(Model.findOrDefault(session.config.model));
const sessions: ISession[] = $derived(Session.all());
const mcpServers: IMcpServer[] = $derived(McpServer.all());
const models: IModel[] = $derived(Model.all());
const engines: IEngine[] = $derived(Engine.all());
let advancedIsOpen = $state(false);
async function modelDidUpdate() {
// `session.config.model` has been updated, so save.
async function modelDidUpdate(model: IModel) {
session.config.model = model.id;
await Session.update(session);
}
@@ -91,7 +92,7 @@
});
afterNavigate(async () => {
if (model && Model.supportsTools(model)) {
if (model.supportsTools) {
await startMcpServers(session);
}
});
@@ -137,20 +138,21 @@
{#if session}
<Flex class="bg-medium h-full w-[calc(100%-600px)] grow items-start">
{#key session.id}
<Chat {session} model={session.config.model} />
<Chat {session} {model} />
{/key}
</Flex>
<Flex class="bg-medium border-light h-full w-[300px] flex-col items-start border-l p-4">
<Select
onSelect={modelDidUpdate}
bind:value={session.config.model}
class="text-light z-50 mb-8 w-full"
options={models.map((m) => m.name).sort()}
/>
{#key session.config.model}
<ModelMenu
{engines}
bind:value={session.config.model}
onselect={modelDidUpdate}
/>
{/key}
{#if model && !Model.supportsTools(model)}
<Flex class="text-red mb-8 w-full justify-start gap-2 pl-3">
{#if model && !model.supportsTools}
<Flex class="text-red w-full justify-start gap-2 pl-3">
<Svg class="h-6 w-6" name="Warning" />
Model doesn't support MCP
</Flex>
@@ -163,20 +165,22 @@
</Flex>
{/if}
{#each mcpServers as server (server.id)}
<Flex class="text-light z-0 mb-4 ml-2">
<Toggle
label={server.name}
value={Session.hasMcpServer(session, server.name) &&
Model.supportsTools(model)
? 'on'
: 'off'}
disabled={!Model.supportsTools(model)}
onEnable={() => startMcpServer(server)}
onDisable={() => stopMcpServer(server)}
/>
</Flex>
{/each}
<div class="mt-4">
{#each mcpServers as server (server.id)}
<Flex class="text-light z-0 mb-4 ml-2">
<Toggle
label={server.name}
value={Session.hasMcpServer(session, server.name) &&
model.supportsTools
? 'on'
: 'off'}
disabled={!model.supportsTools}
onEnable={() => startMcpServer(server)}
onDisable={() => stopMcpServer(server)}
/>
</Flex>
{/each}
</div>
<Flex class="mt-8 w-full flex-col items-start">
<button

View File

@@ -1,7 +1,7 @@
import type { PageLoad } from './$types';
import type { CompactServer } from '$lib/smithery';
import { Client } from '$lib/smithery/client';
import type { CompactServer } from '$lib/smithery/types';
interface Response {
servers: CompactServer[];

View File

@@ -4,7 +4,7 @@
import Box from '$components/Box.svelte';
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Model, { type IModel } from '$lib/models/model.svelte';
import Model, { type IModel } from '$lib/models/model';
const models: IModel[] = Model.all().sortBy('name');
</script>
@@ -32,9 +32,9 @@
<Box class="border-b-light text-light w-full border-b px-8 py-4">
<h2 class="mr-2">{model.name}</h2>
<div class="grow"></div>
{@render label(model.details.format, 'bg-[#7aa2f7]')}
{@render label(model.details.parameter_size, 'bg-[#ff9e64]')}
{@render label(model.details.quantization_level, 'bg-[#41a6b5]')}
{@render label(model.metadata.details.format, 'bg-[#7aa2f7]')}
{@render label(model.metadata.details.parameter_size, 'bg-[#ff9e64]')}
{@render label(model.metadata.details.quantization_level, 'bg-[#41a6b5]')}
</Box>
{/each}
</Flex>

View File

@@ -1,7 +1,7 @@
import type { PageLoad } from "./$types";
import Model from "$lib/models/model.svelte";
import { Engine } from "$lib/models";
export const load: PageLoad = async (): Promise<void> => {
await Model.sync();
await Engine.sync();
}

View File

@@ -8,7 +8,6 @@
"resolveJsonModule": true,
"inlineSourceMap": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}