Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fed26112 | ||
|
|
c16bf9892a | ||
|
|
b68b112bd8 | ||
|
|
aeb8f71167 | ||
|
|
13037ecb14 | ||
|
|
4512d2b76d | ||
|
|
f09d55d0e2 | ||
|
|
1548f78c73 | ||
|
|
6137793ac4 | ||
|
|
b859a94509 | ||
|
|
45a02807e2 | ||
|
|
8d66f08355 | ||
|
|
ea8d65004a | ||
|
|
c951dabec4 | ||
|
|
159b1c3331 | ||
|
|
d8ab4b4f0c | ||
|
|
3738935d0c | ||
|
|
86f7292934 | ||
|
|
cd1c5a145d | ||
|
|
9160b3c0b8 | ||
|
|
3e02feac8d | ||
|
|
1b45aa19da | ||
|
|
34afa1a0c8 | ||
|
|
b5d373f340 | ||
|
|
9ef6c19341 | ||
|
|
3b5ab1fcfc | ||
|
|
83ed7d9965 | ||
|
|
b043d891c2 | ||
|
|
43e667004a | ||
|
|
d3efc63263 | ||
|
|
87b9f9e95a | ||
|
|
3f77cb6dfb | ||
|
|
cf8b605b06 | ||
|
|
b68365a0a4 | ||
|
|
4986e92379 | ||
|
|
59fe23d85c | ||
|
|
b79067b0db | ||
|
|
3ffc9a682a | ||
|
|
ba064c3662 | ||
|
|
c36adebc20 | ||
|
|
882387a57b | ||
|
|
b1d1ccc957 | ||
|
|
855f4bf989 | ||
|
|
e5e4377115 | ||
|
|
5ec5804360 | ||
|
|
3ba380d0be | ||
|
|
f348c7e3f5 | ||
|
|
205a042690 | ||
|
|
ef117cfcc0 | ||
|
|
e37a00a8fa | ||
|
|
f96e7aa051 | ||
|
|
68793119df | ||
|
|
f4025ed1fe | ||
|
|
39f7f91563 | ||
|
|
d366f4868a | ||
|
|
a67fe8c537 | ||
|
|
13f2fb8c99 | ||
|
|
3b96a8972a | ||
|
|
91cf995996 | ||
|
|
a773b7affa | ||
|
|
d3a8af4d9b | ||
|
|
18ea9de0fe | ||
|
|
e2c6c7e894 | ||
|
|
9dbf139133 | ||
|
|
77e3b0ac7a | ||
|
|
147509c0c5 | ||
|
|
7623501fc2 | ||
|
|
7027546ccf | ||
|
|
3d86a552b8 | ||
|
|
d465dc7ca1 | ||
|
|
ad94d52965 | ||
|
|
ebf200f952 | ||
|
|
075aa7e147 | ||
|
|
377ef091dc | ||
|
|
6e19e916c7 | ||
|
|
f6c59d8703 | ||
|
|
82ad9fa38b | ||
|
|
60d83f8b0e | ||
|
|
e2e82903fa | ||
|
|
939425152e | ||
|
|
02dc3ea4c3 | ||
|
|
fb30760996 | ||
|
|
add1f92bdd | ||
|
|
124e9af9d2 | ||
|
|
2d9622a4d4 | ||
|
|
30019c87c4 | ||
|
|
c44575ef14 | ||
|
|
81f9a394e9 | ||
|
|
30e2891808 | ||
|
|
b315b3ee97 | ||
|
|
c01eafa552 | ||
|
|
8f912415e8 | ||
|
|
5ad1681cf1 | ||
|
|
30546d1fd4 | ||
|
|
6bba484f1a | ||
|
|
e92b7855f6 | ||
|
|
4e13421378 | ||
|
|
b7871fc0d1 | ||
|
|
e0c95a5ffd | ||
|
|
b8a6272440 | ||
|
|
d1bd15ccce | ||
|
|
0bb0925920 | ||
|
|
a5165c53ac | ||
|
|
cd37044093 | ||
|
|
d67ad83426 | ||
|
|
e5a81899d0 | ||
|
|
78e818e3ce | ||
|
|
87abaa019e | ||
|
|
f427fa0753 | ||
|
|
0f25c31bd5 | ||
|
|
5512797527 | ||
|
|
39df91c982 | ||
|
|
7ce88bd686 | ||
|
|
01d04d50f8 | ||
|
|
9a56a0e845 | ||
|
|
88313499e0 | ||
|
|
2b4a99376c | ||
|
|
e922bc7125 | ||
|
|
950dd9ac67 | ||
|
|
b6b1b258c3 | ||
|
|
4138e853d5 | ||
|
|
641df5e5ad | ||
|
|
6a261db7d8 | ||
|
|
948f69330a | ||
|
|
96fb6d2fa9 | ||
|
|
400164d47c | ||
|
|
bb7bba885e | ||
|
|
4f066a6fb0 | ||
|
|
a836eb8c3c | ||
|
|
1a9e4235ac | ||
|
|
43f4eda808 | ||
|
|
acdf81510d | ||
|
|
1e85fe8e9a | ||
|
|
5fcf71b8b8 | ||
|
|
60bdc23467 | ||
|
|
694487c65b | ||
|
|
af5b627672 | ||
|
|
8713570d3c | ||
|
|
5a173edb63 | ||
|
|
b880b76c43 | ||
|
|
cf129bbb11 | ||
|
|
c1bf60d7b1 | ||
|
|
0fcfc99ea0 | ||
|
|
e7fbd528f7 | ||
|
|
2f84872ce0 | ||
|
|
f91cb0d6a7 | ||
|
|
8dd34b77d1 | ||
|
|
35f71382de | ||
|
|
144893850f | ||
|
|
26936bea45 | ||
|
|
43983edf18 | ||
|
|
c82ebbd5bf | ||
|
|
8edecb5c69 | ||
|
|
8d8c701d6a | ||
|
|
b63eed94b6 | ||
|
|
4f9d09ef52 | ||
|
|
ba8f2a324d | ||
|
|
721f05049d | ||
|
|
accd6d656a | ||
|
|
bd3f22e2f7 | ||
|
|
5db07cad71 | ||
|
|
acf96e7ab7 | ||
|
|
a199313eb3 | ||
|
|
b205993ed4 | ||
|
|
9ea503f505 | ||
|
|
3ff2dcb649 | ||
|
|
4713800b08 | ||
|
|
0e2e34aa86 | ||
|
|
76929b0a8a | ||
|
|
bf7be180e3 | ||
|
|
eec8e0b945 | ||
|
|
be7572e2cf | ||
|
|
64ee2f9847 | ||
|
|
132e0ca25d | ||
|
|
9eef4a7195 | ||
|
|
3f07e022ac | ||
|
|
551809688b | ||
|
|
08edcb8f2c | ||
|
|
d6f69da04d | ||
|
|
6cd40001a9 | ||
|
|
a84b315e7b | ||
|
|
83d4311ffe | ||
|
|
fce745fe5e | ||
|
|
1fd1ccf7ad | ||
|
|
efb7e3c55e | ||
|
|
b517b9bd01 | ||
|
|
479664e7aa | ||
|
|
a51df81573 | ||
|
|
a18c230189 | ||
|
|
e21d5327b0 | ||
|
|
4d1b53e6fb | ||
|
|
6100aca085 | ||
|
|
72116bafb3 | ||
|
|
e2c2edcc27 | ||
|
|
de1ce45913 | ||
|
|
51fc00122b | ||
|
|
445284b428 | ||
|
|
e8f7c93a0b | ||
|
|
0de38190d7 | ||
|
|
681dfaedcc | ||
|
|
9893d39218 | ||
|
|
5ba17e6fdf | ||
|
|
6f3b997dec | ||
|
|
cc62676656 | ||
|
|
573386c35e | ||
|
|
794a254111 | ||
|
|
714b6fc2a4 | ||
|
|
61e1b1ba5e | ||
|
|
5865a01e48 | ||
|
|
e61c1c73fe | ||
|
|
03d61e1925 | ||
|
|
30c390384e | ||
|
|
d590830091 | ||
|
|
fdcf9efafd | ||
|
|
76188f60cd | ||
|
|
8a0016f826 | ||
|
|
d49b96d9ab | ||
|
|
3bd506bd1c | ||
|
|
123b1f2479 | ||
|
|
556245843a | ||
|
|
c963822dca | ||
|
|
dd49563d55 | ||
|
|
4e96f4dbf2 | ||
|
|
d573a2367b | ||
|
|
4f7786d0ba | ||
|
|
f1a0ffd621 | ||
|
|
cd600e19a3 | ||
|
|
59bd0b49bb | ||
|
|
82f905cd9c | ||
|
|
cb3d98ccb2 | ||
|
|
d47859ce49 | ||
|
|
a6293eb516 | ||
|
|
892e7f6be6 | ||
|
|
f3d69a3dee | ||
|
|
67b6a1c2d4 | ||
|
|
87b64213b4 | ||
|
|
f2d069f6df | ||
|
|
5208ae7500 | ||
|
|
9d779572a7 | ||
|
|
964ea42c09 | ||
|
|
dba1e27fa8 | ||
|
|
e436db25ff | ||
|
|
26acfa42b5 | ||
|
|
7b22ac9683 | ||
|
|
a2b3a5e9a3 | ||
|
|
624cada952 | ||
|
|
cecd265d3a | ||
|
|
2ea95fb059 | ||
|
|
8e7be3aed1 | ||
|
|
710292ff4f | ||
|
|
ada1eb5163 | ||
|
|
1c5ebbf5f4 | ||
|
|
7926b99e0e | ||
|
|
32a97b7493 | ||
|
|
d26a58557d | ||
|
|
2e474c98f9 | ||
|
|
2cb2c5381f | ||
|
|
2a6b50421a | ||
|
|
f22ec2ec49 | ||
|
|
d9075caf1a | ||
|
|
e11eeb3ba0 | ||
|
|
0a408b2225 | ||
|
|
16739dee60 | ||
|
|
d48d790baf | ||
|
|
0463940334 | ||
|
|
570679c9e0 | ||
|
|
89a171cc70 | ||
|
|
33878e671a | ||
|
|
c191a145bb | ||
|
|
479e1cf94e | ||
|
|
836507378b | ||
|
|
46bc1bcb4c | ||
|
|
2a8b31531e | ||
|
|
505e35f2b9 | ||
|
|
114875133b | ||
|
|
42c330283b | ||
|
|
f93efe2809 | ||
|
|
28fbbb06d5 | ||
|
|
340c51bbb7 | ||
|
|
2e9d68dc38 | ||
|
|
fc58544422 | ||
|
|
e434a93884 | ||
|
|
9c02d8e69d | ||
|
|
07ed752353 | ||
|
|
e1f7f9cbdb | ||
|
|
8c432fc88a | ||
|
|
acfb50d9af | ||
|
|
0f047feef5 | ||
|
|
9e4ed74efe | ||
|
|
bbb40a0a6c | ||
|
|
993acc7504 | ||
|
|
7ea692cb2b | ||
|
|
12e04379cd | ||
|
|
f8a48df24d | ||
|
|
82e0ddb6fe | ||
|
|
1abd56b6e6 | ||
|
|
ded2db7d86 | ||
|
|
d00622060f | ||
|
|
177aefb8a9 | ||
|
|
07588c64ee | ||
|
|
4c97a940ca | ||
|
|
74cbf1d2c2 | ||
|
|
5c1e37eb67 | ||
|
|
f0078ae476 | ||
|
|
96201a623a | ||
|
|
9c94c2b11e | ||
|
|
e09b3f9fb5 | ||
|
|
a0099da2d1 | ||
|
|
25e0e81e12 | ||
|
|
87cff95af8 | ||
|
|
3ef69ef784 | ||
|
|
1a7786be14 | ||
|
|
3370ff8b1c | ||
|
|
455f57457d | ||
|
|
1d955ed990 | ||
|
|
d071237131 | ||
|
|
229a1303fb | ||
|
|
ac3d0657a2 | ||
|
|
01557ff313 | ||
|
|
e5a38739b4 | ||
|
|
5f56a289b3 | ||
|
|
ad8cda255d | ||
|
|
3e1b4fe39d | ||
|
|
52196f1a97 | ||
|
|
50bbda5660 | ||
|
|
4b5bdd3b25 | ||
|
|
e828061b6e | ||
|
|
7a2073d17b | ||
|
|
c952708169 | ||
|
|
f57d111754 | ||
|
|
c34a79a373 | ||
|
|
b051c9cf83 | ||
|
|
b7b7fa0454 | ||
|
|
4c076813be | ||
|
|
6780f0416a | ||
|
|
35fa277fa9 | ||
|
|
05747b02ab | ||
|
|
4e807fdedd | ||
|
|
7d3a6c3ae5 | ||
|
|
06ff728246 | ||
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 | ||
|
|
f63eea3d27 | ||
|
|
632ff00798 | ||
|
|
275f122cda | ||
|
|
32568531bd | ||
|
|
438fb991e4 | ||
|
|
358af4af23 | ||
|
|
91c8e5e1a8 | ||
|
|
4b2d529966 | ||
|
|
e6b1d751f2 | ||
|
|
56b319f457 | ||
|
|
42e6f56c2a | ||
|
|
da679adcde | ||
|
|
b9c0421f03 | ||
|
|
98e26b8c37 | ||
|
|
c28ddc0a7b | ||
|
|
3ad2fa3fb5 | ||
|
|
6b6f45ef0e | ||
|
|
4860130f83 | ||
|
|
ac7295ccab | ||
|
|
6398cd5b78 | ||
|
|
3af1a008e2 | ||
|
|
6bdb73073b | ||
|
|
421faa0263 | ||
|
|
206b049508 | ||
|
|
d819ef0f97 | ||
|
|
3d5a011a2e | ||
|
|
c2f2d90a67 | ||
|
|
1e1b34dada | ||
|
|
f866e7608f | ||
|
|
bab59072fb | ||
|
|
7c2c36bda2 | ||
|
|
d319227df0 | ||
|
|
2d84ec939c | ||
|
|
15e6076d79 | ||
|
|
4017af96cd | ||
|
|
534342e7e2 | ||
|
|
9ba5a04914 | ||
|
|
938ca6e274 | ||
|
|
8f39fff70b | ||
|
|
4fe5609563 | ||
|
|
917324bb4d | ||
|
|
c7c2837c96 | ||
|
|
b6447caebc | ||
|
|
bad32c7244 | ||
|
|
ab2e005bf7 | ||
|
|
321cc8a2ba | ||
|
|
87288ced4f | ||
|
|
2bbe2405fe | ||
|
|
bd21678b16 | ||
|
|
088dfd89a8 | ||
|
|
3cab8a7b02 | ||
|
|
03aee88186 | ||
|
|
ec9b4e9e47 | ||
|
|
4656a07e56 | ||
|
|
30f86cb9dd | ||
|
|
ea01af6f76 | ||
|
|
c2ebb4d57c | ||
|
|
590109c835 | ||
|
|
b4442c6d17 | ||
|
|
85ff8e4a21 | ||
|
|
160660e572 | ||
|
|
3b43b9bc4b | ||
|
|
21883571b7 | ||
|
|
ce99f24731 | ||
|
|
04f5f0cdb4 | ||
|
|
fb36a01ffe | ||
|
|
0c65ed33bc | ||
|
|
22d6c817f8 | ||
|
|
ca01373b28 | ||
|
|
24e038d56a | ||
|
|
5d1021603a | ||
|
|
8e05d734b9 | ||
|
|
05e0f21bec | ||
|
|
ff23dd343f | ||
|
|
123b300af6 | ||
|
|
57653b8e42 | ||
|
|
a50ce61c54 | ||
|
|
2bb7ea00d2 | ||
|
|
55fa80d07a | ||
|
|
b9cb535407 | ||
|
|
031baef094 | ||
|
|
7d271e6dc9 | ||
|
|
c88dae2d6b | ||
|
|
9e3618d663 | ||
|
|
5d920cc6bc | ||
|
|
e585ecd11f | ||
|
|
cdddea0592 | ||
|
|
43f90def04 | ||
|
|
06ae6367bd | ||
|
|
48ad7085c4 | ||
|
|
e1e3cec8d0 | ||
|
|
d3e67e305c | ||
|
|
698e04a14b | ||
|
|
1d9537bc33 | ||
|
|
120424d832 | ||
|
|
5818001610 | ||
|
|
2cba7756c5 | ||
|
|
bf2a421727 | ||
|
|
f3cf6b75fb | ||
|
|
5dfac387a6 | ||
|
|
a99e5d9c22 | ||
|
|
0abf3aca36 | ||
|
|
ee0266462a | ||
|
|
c88fb286ec | ||
|
|
d3da29cbfc | ||
|
|
1b70bb8a10 | ||
|
|
ec29ce4ce3 | ||
|
|
4d75f5da03 | ||
|
|
798fd09bfe | ||
|
|
9330bb9120 | ||
|
|
40a1317dfd | ||
|
|
fdfe9cec98 | ||
|
|
9517864603 | ||
|
|
8e6d86dbe3 | ||
|
|
80d3744c5d | ||
|
|
2a94f03823 | ||
|
|
eb97274e5c | ||
|
|
6b5db12aa2 | ||
|
|
612f0a17d3 | ||
|
|
673726fa0e | ||
|
|
b5918f9785 | ||
|
|
d17f482d50 | ||
|
|
4e16f562c0 | ||
|
|
55308f1421 | ||
|
|
d64812eb5d | ||
|
|
f86a969f27 | ||
|
|
9fa80a1660 | ||
|
|
dde09129d1 | ||
|
|
780556c4d0 | ||
|
|
dfae363b5b | ||
|
|
30fdd229a4 | ||
|
|
e823bff873 | ||
|
|
8968740836 | ||
|
|
8c8f8f3450 | ||
|
|
82f0139587 | ||
|
|
26a58b294c | ||
|
|
34a790a2e6 | ||
|
|
4589fa2cf5 | ||
|
|
4bc2728047 | ||
|
|
49d5fd5a3e | ||
|
|
3cd2b03a5e | ||
|
|
c8e0878814 | ||
|
|
bb0c58e134 | ||
|
|
036ed1b9b5 | ||
|
|
3536ef58f6 | ||
|
|
de9673ac3f | ||
|
|
96b202d34b | ||
|
|
79865e6c5a | ||
|
|
5ab10d347a | ||
|
|
a8292dd85f | ||
|
|
cb0033598e | ||
|
|
4d14b0ff92 | ||
|
|
d9cb70c270 | ||
|
|
31f968fe1f | ||
|
|
b7bda92d52 | ||
|
|
8e54823fd3 | ||
|
|
7c8da5679e | ||
|
|
6214103e66 | ||
|
|
9e7cb9697e | ||
|
|
3824e380a8 | ||
|
|
c9b2dcfc52 | ||
|
|
b00bd1dfd4 | ||
|
|
ac83ac20c4 | ||
|
|
e7ccc129ea | ||
|
|
69ed0c2729 | ||
|
|
1cefa749aa | ||
|
|
aec2fef95d | ||
|
|
366625a831 | ||
|
|
516ebd8548 | ||
|
|
f567abc63f | ||
|
|
1adfc27f04 | ||
|
|
4a2b9f9dbc | ||
|
|
e46b67a6cc | ||
|
|
c000afe76c | ||
|
|
9d7b18f81e | ||
|
|
4f5999fd3f | ||
|
|
ac5f0dbb6a | ||
|
|
d1151e18a1 | ||
|
|
ebbce136c7 | ||
|
|
26b9f53f8e | ||
|
|
7575438366 | ||
|
|
7d7c90d702 | ||
|
|
4fda69809a | ||
|
|
c9b5da6b0c | ||
|
|
de5cb7311f | ||
|
|
95ee7fbd29 | ||
|
|
ec55536734 | ||
|
|
77491439c2 | ||
|
|
b166b36cd2 | ||
|
|
c2b0bb7a52 |
No files matched your search
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Prepare MLX runtime libraries for macOS CI unit tests.
|
||||
#
|
||||
# Building MLX is expensive, so to enable the MLX-specific unit tests this
|
||||
# helper finds the newest Ollama release whose MLX_VERSION and MLX_C_VERSION
|
||||
# match the current checkout, downloads that release's ollama-darwin.tgz, and
|
||||
# extracts only mlx_metal_v* into build/lib/ollama.
|
||||
#
|
||||
# The payload also depends on Ollama's payload build rules (cmake glue and
|
||||
# carried mlx/compat patches) and the xgrammar native wrapper
|
||||
# (x/mlxrunner/xgrammar/native). Rule drift rebuilds the whole payload from
|
||||
# source; wrapper-only drift rebuilds just libollama_xgrammar.dylib.
|
||||
#
|
||||
# If no release matches the MLX pins (e.g. right after a pin bump), the
|
||||
# helper builds the minimal MLX payload for this platform: a single Metal
|
||||
# variant using the superbuild's platform default (metal_v4 on macOS 26.2+
|
||||
# SDKs, otherwise metal_v3), including a fresh libollama_xgrammar.dylib.
|
||||
# Built payloads are cached in the release tarball's layout for later runs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo="${OLLAMA_MLX_RELEASE_REPO:-ollama/ollama}"
|
||||
scan_limit="${OLLAMA_MLX_RELEASE_SCAN_LIMIT:-50}"
|
||||
cache_dir="${OLLAMA_MLX_DARWIN_CACHE:-.cache/mlx-darwin-release}"
|
||||
target_dir="${OLLAMA_MLX_DARWIN_TARGET:-build/lib/ollama}"
|
||||
ci_build_dir="${OLLAMA_MLX_CI_BUILD_DIR:-build/mlx-ci}"
|
||||
tarball="${cache_dir}/ollama-darwin.tgz"
|
||||
tag_file="${cache_dir}/matched-tag"
|
||||
pins_file="${cache_dir}/matched-pins"
|
||||
target_pins_file="${target_dir}/.mlx-release-pins"
|
||||
# ${tag_file} value for a cached local build.
|
||||
local_tag="local-build"
|
||||
tmpdir=""
|
||||
tmp_tarball=""
|
||||
|
||||
cleanup() {
|
||||
[ -z "${tmpdir}" ] || rm -rf "${tmpdir}"
|
||||
[ -z "${tmp_tarball}" ] || rm -f "${tmp_tarball}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
warn() {
|
||||
if [ -n "${GITHUB_ACTIONS:-}" ]; then
|
||||
echo "::warning::$*"
|
||||
else
|
||||
echo "warning: $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
read_pin() {
|
||||
tr -d '[:space:]' <"$1"
|
||||
}
|
||||
|
||||
# Native wrapper sources compiled into libollama_xgrammar.dylib — keep in
|
||||
# sync with the ollama_xgrammar target in cmake/mlx/CMakeLists.txt.
|
||||
xgrammar_native_dir=x/mlxrunner/xgrammar/native
|
||||
|
||||
# Payload build rules beyond the MLX_VERSION/MLX_C_VERSION pins.
|
||||
payload_rule_files=(
|
||||
"cmake/local.cmake"
|
||||
"cmake/apply-git-patches.cmake"
|
||||
"cmake/mlx/CMakeLists.txt"
|
||||
"cmake/mlx/CMakePresets.json"
|
||||
"x/mlxrunner/mlx/CMakeLists.txt"
|
||||
)
|
||||
|
||||
# Build-rule inputs: the rule files plus carried MLX/MLX-C patch content.
|
||||
rule_inputs() {
|
||||
local file
|
||||
for file in "${payload_rule_files[@]}"; do
|
||||
printf '%s\n' "${file}"
|
||||
done
|
||||
if [ -d mlx/compat ]; then
|
||||
find mlx/compat -type f | sort
|
||||
fi
|
||||
}
|
||||
|
||||
wrapper_inputs() {
|
||||
find "${xgrammar_native_dir}" -type f | sort
|
||||
}
|
||||
|
||||
payload_inputs() {
|
||||
rule_inputs
|
||||
wrapper_inputs
|
||||
}
|
||||
|
||||
payload_fingerprint() {
|
||||
local file
|
||||
{
|
||||
payload_inputs
|
||||
while IFS= read -r file; do
|
||||
cat "${file}" 2>/dev/null || true
|
||||
done < <(payload_inputs)
|
||||
} | shasum -a 256 | awk '{print $1}'
|
||||
}
|
||||
|
||||
# True when the tag matches the checkout on all payload inputs.
|
||||
tag_matches_payload() {
|
||||
local tag="$1" file
|
||||
while IFS= read -r file; do
|
||||
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
|
||||
return 1
|
||||
fi
|
||||
done < <(payload_inputs)
|
||||
return 0
|
||||
}
|
||||
|
||||
# True when the tag matches the checkout on the build rules.
|
||||
tag_matches_rules() {
|
||||
local tag="$1" file
|
||||
while IFS= read -r file; do
|
||||
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
|
||||
return 1
|
||||
fi
|
||||
done < <(rule_inputs)
|
||||
return 0
|
||||
}
|
||||
|
||||
has_payload() {
|
||||
local variant
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
[ -f "${variant}/libmlx.dylib" ] && [ -f "${variant}/libmlxc.dylib" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_matching_payload() {
|
||||
[ -f "${target_pins_file}" ] || return 1
|
||||
[ "$(cat "${target_pins_file}")" = "${current_pins}" ] || return 1
|
||||
has_payload || return 1
|
||||
# Every payload variant must carry libollama_xgrammar.dylib.
|
||||
local variant
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
[ -f "${variant}/libollama_xgrammar.dylib" ] || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
extract_payload() {
|
||||
local tag="$1"
|
||||
tmpdir="$(mktemp -d)"
|
||||
|
||||
tar -xzf "${tarball}" -C "${tmpdir}"
|
||||
mkdir -p "${target_dir}"
|
||||
|
||||
rm -rf "${target_dir}"/mlx_metal_v*
|
||||
|
||||
local found=false
|
||||
local src dest
|
||||
for src in "${tmpdir}"/mlx_metal_v*; do
|
||||
[ -d "${src}" ] || continue
|
||||
found=true
|
||||
dest="${target_dir}/$(basename "${src}")"
|
||||
rm -rf "${dest}"
|
||||
cp -R "${src}" "${dest}"
|
||||
done
|
||||
|
||||
if [ "${found}" != true ] || ! has_payload; then
|
||||
echo "Downloaded ${tarball} did not contain a usable MLX Metal payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${current_pins}" >"${target_pins_file}"
|
||||
echo "Prepared MLX Darwin payload from ${repo} ${tag}:"
|
||||
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name '*.metallib' \) -print
|
||||
|
||||
rm -rf "${tmpdir}"
|
||||
tmpdir=""
|
||||
}
|
||||
|
||||
# Cache the built payload in the release tarball's layout.
|
||||
save_built_payload() {
|
||||
local variant
|
||||
local -a variants=()
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
variants+=("$(basename "${variant}")")
|
||||
done
|
||||
tmp_tarball="${tarball}.tmp"
|
||||
tar -czf "${tmp_tarball}" -C "${target_dir}" "${variants[@]}"
|
||||
mv "${tmp_tarball}" "${tarball}"
|
||||
tmp_tarball=""
|
||||
echo "${local_tag}" >"${tag_file}"
|
||||
echo "${current_pins}" >"${pins_file}"
|
||||
echo "Cached the built payload in ${cache_dir}"
|
||||
}
|
||||
|
||||
# Resolve the superbuild's platform-default MLX backend (metal_v3/metal_v4 on
|
||||
# arm64; empty when the platform has no MLX backend, e.g. x86_64 macOS).
|
||||
ci_mlx_backend() {
|
||||
[ -f "${ci_build_dir}/CMakeCache.txt" ] || return 1
|
||||
sed -n 's/^OLLAMA_MLX_BACKENDS:STRING=//p' "${ci_build_dir}/CMakeCache.txt"
|
||||
}
|
||||
|
||||
# Configure the repo-root superbuild and fetch MLX/MLX-C sources at the
|
||||
# pinned revisions (only the full payload build needs this).
|
||||
build_ci_sources() {
|
||||
cmake -S . -B "${ci_build_dir}" \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_PAYLOAD_INSTALL_PREFIX="$(dirname "$(dirname "${target_dir}")")"
|
||||
cmake --build "${ci_build_dir}" --target ollama-mlx-sources
|
||||
}
|
||||
|
||||
# Rebuild only libollama_xgrammar.dylib into the extracted payload. The
|
||||
# target depends only on the pinned XGrammar sources and the native wrapper;
|
||||
# the Metal toolchain and the superbuild are not involved. MLX is fetched
|
||||
# only because the cmake/mlx project defines it — nothing from it is built.
|
||||
build_ci_xgrammar() {
|
||||
local lib variant
|
||||
local xg_build_dir="${ci_build_dir}/xgrammar"
|
||||
local -a configure_args=(-S cmake/mlx -B "${xg_build_dir}" -DOLLAMA_SOURCE_DIR="$(pwd)" -DMLX_BUILD_METAL=OFF)
|
||||
if [ -n "${OLLAMA_XGRAMMAR_SOURCE:-}" ]; then
|
||||
configure_args+=("-DFETCHCONTENT_SOURCE_DIR_XGRAMMAR=${OLLAMA_XGRAMMAR_SOURCE}")
|
||||
fi
|
||||
cmake "${configure_args[@]}"
|
||||
cmake --build "${xg_build_dir}" --target ollama_xgrammar
|
||||
lib="${xg_build_dir}/lib/ollama/libollama_xgrammar.dylib"
|
||||
[ -f "${lib}" ] || {
|
||||
echo "ollama_xgrammar build produced no library at ${lib}" >&2
|
||||
exit 1
|
||||
}
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
cp -f "${lib}" "${variant}/libollama_xgrammar.dylib"
|
||||
[ -f "${variant}/libollama_xgrammar.dylib" ] || {
|
||||
echo "failed to install ${variant}/libollama_xgrammar.dylib" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "Rebuilt libollama_xgrammar.dylib from source into ${target_dir}"
|
||||
}
|
||||
|
||||
# Build the minimal MLX payload for this platform: one Metal variant,
|
||||
# whatever the superbuild defaults to here.
|
||||
build_ci_payload() {
|
||||
local backend variant
|
||||
build_ci_sources
|
||||
backend="$(ci_mlx_backend)"
|
||||
case "${backend}" in
|
||||
metal_v3 | metal_v4) ;;
|
||||
*)
|
||||
warn "no MLX backend applicable to this platform; MLX unit tests will be skipped"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "Building the ${backend} payload for unit tests"
|
||||
rm -rf "${target_dir}"/mlx_metal_v*
|
||||
cmake --build "${ci_build_dir}" --target "ollama-mlx-${backend}"
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
for lib in libmlx.dylib libmlxc.dylib libollama_xgrammar.dylib; do
|
||||
[ -f "${variant}/${lib}" ] || {
|
||||
echo "built payload is missing ${variant}/${lib}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
done
|
||||
has_payload || {
|
||||
echo "built payload is incomplete in ${target_dir}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "${current_pins}" >"${target_pins_file}"
|
||||
echo "Built MLX payload for unit tests:"
|
||||
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name 'libollama_xgrammar.dylib' -o -name '*.metallib' \) -print
|
||||
save_built_payload
|
||||
}
|
||||
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
warn "MLX Darwin payload setup is only supported on macOS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(sysctl -n hw.ncpu)}"
|
||||
|
||||
current_mlx="$(read_pin MLX_VERSION)"
|
||||
current_mlxc="$(read_pin MLX_C_VERSION)"
|
||||
# The release tarball only depends on the MLX pins; the extracted payload's
|
||||
# xgrammar library additionally depends on the tree's XGrammar inputs.
|
||||
component_pins="${current_mlx} ${current_mlxc}"
|
||||
current_pins="${component_pins} $(payload_fingerprint)"
|
||||
|
||||
if has_matching_payload; then
|
||||
echo "MLX payload already present in ${target_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "${cache_dir}"
|
||||
|
||||
# Release tarballs are keyed on the MLX pins; local builds on the full fingerprint.
|
||||
cached_pins="$(cat "${pins_file}" 2>/dev/null || true)"
|
||||
if [ -s "${tarball}" ] && [ -f "${tag_file}" ] && { [ "${cached_pins}" = "${component_pins}" ] || [ "${cached_pins}" = "${current_pins}" ]; }; then
|
||||
extract_payload "$(cat "${tag_file}")"
|
||||
else
|
||||
matched_tag=""
|
||||
matched_url=""
|
||||
|
||||
while read -r tag; do
|
||||
[ -n "${tag}" ] || continue
|
||||
|
||||
if ! tag_mlx="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_VERSION" | tr -d '[:space:]')"; then
|
||||
continue
|
||||
fi
|
||||
if [ "${tag_mlx}" != "${current_mlx}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! tag_mlxc="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_C_VERSION" | tr -d '[:space:]')"; then
|
||||
continue
|
||||
fi
|
||||
if [ "${tag_mlxc}" != "${current_mlxc}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
url="https://github.com/${repo}/releases/download/${tag}/ollama-darwin.tgz"
|
||||
if curl -fsIL "${url}" >/dev/null; then
|
||||
matched_tag="${tag}"
|
||||
matched_url="${url}"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "MLX pins match ${tag}, but ${url} is not available"
|
||||
done < <(
|
||||
git ls-remote --tags --refs --sort=-version:refname "https://github.com/${repo}.git" 'v*' |
|
||||
awk -v limit="${scan_limit}" '{ sub("refs/tags/", "", $2); print $2; if (limit > 0 && NR >= limit) exit }'
|
||||
)
|
||||
|
||||
if [ -z "${matched_tag}" ]; then
|
||||
echo "No release carries MLX_VERSION=${current_mlx} MLX_C_VERSION=${current_mlxc}"
|
||||
build_ci_payload
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_tarball="${tarball}.tmp"
|
||||
rm -f "${tmp_tarball}"
|
||||
curl -fL --retry 3 --retry-delay 2 -o "${tmp_tarball}" "${matched_url}"
|
||||
mv "${tmp_tarball}" "${tarball}"
|
||||
tmp_tarball=""
|
||||
echo "${matched_tag}" >"${tag_file}"
|
||||
echo "${component_pins}" >"${pins_file}"
|
||||
|
||||
extract_payload "${matched_tag}"
|
||||
fi
|
||||
|
||||
tag="$(cat "${tag_file}")"
|
||||
if [ "${tag}" = "${local_tag}" ] || tag_matches_payload "${tag}"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(uname -m)" != "arm64" ]; then
|
||||
warn "MLX payload builds are only supported on arm64 macOS; MLX unit tests will be skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if tag_matches_rules "$(cat "${tag_file}")"; then
|
||||
# Only the xgrammar wrapper drifted; keep the rest of the release payload.
|
||||
echo "Rebuilding libollama_xgrammar.dylib from source into ${target_dir}"
|
||||
rm -f "${target_dir}"/mlx_metal_v*/libollama_xgrammar.dylib
|
||||
build_ci_xgrammar
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Release payload build rules do not match this checkout"
|
||||
build_ci_payload
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
run: |
|
||||
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-14-xlarge
|
||||
runs-on: macos-26-xlarge
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
@@ -39,11 +39,27 @@ jobs:
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
MACOS_SIGNING_KEY: ${{ secrets.MACOS_SIGNING_KEY }}
|
||||
MACOS_SIGNING_KEY_PASSWORD: ${{ secrets.MACOS_SIGNING_KEY_PASSWORD }}
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.4.1.app/Contents/Developer
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 26.4.1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -d "${DEVELOPER_DIR}" ]; then
|
||||
echo "Missing ${DEVELOPER_DIR}"
|
||||
ls -1 /Applications | grep '^Xcode' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo xcode-select -s "${DEVELOPER_DIR}"
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun --find metal
|
||||
- run: |
|
||||
echo $MACOS_SIGNING_KEY | base64 --decode > certificate.p12
|
||||
security create-keychain -p password build.keychain
|
||||
@@ -57,8 +73,12 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- run: |
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
run: |
|
||||
@@ -73,15 +93,19 @@ jobs:
|
||||
dist/*.dmg
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -89,10 +113,10 @@ jobs:
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -103,23 +127,39 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 6'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
rocm-version: '6.2'
|
||||
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
runner_dir: 'rocm'
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
flags: ''
|
||||
runner_dir: 'vulkan'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'MLX CUDA 13'
|
||||
build-steps: mlxCuda13
|
||||
build-parallel: '16'
|
||||
cmake-cuda-flags: '-t 6'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
cuda-components:
|
||||
@@ -131,22 +171,43 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 200GB
|
||||
disk-root: "D:"
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -161,8 +222,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
@@ -195,12 +266,12 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'CPU'
|
||||
run: |
|
||||
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
@@ -232,72 +303,63 @@ jobs:
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build target "${{ matrix.preset }}"
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
|
||||
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
|
||||
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
|
||||
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
|
||||
windows-build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
llvmarch: x86_64
|
||||
- os: windows
|
||||
arch: arm64
|
||||
llvmarch: aarch64
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install ARM64 system dependencies
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
|
||||
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
|
||||
|
||||
choco install -y --no-progress git gzip
|
||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -314,20 +376,30 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: |
|
||||
./scripts/build_windows ollama app
|
||||
./scripts/build_windows ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||
name: build-windows-amd64
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [windows-build, windows-depends]
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
@@ -353,7 +425,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -367,6 +441,28 @@ jobs:
|
||||
- name: Log dist contents after download
|
||||
run: |
|
||||
gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Collect Go licenses
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
done
|
||||
- run: |
|
||||
./scripts/build_windows.ps1 deps sign installer zip
|
||||
- name: Log contents after build
|
||||
@@ -380,20 +476,34 @@ jobs:
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
linux-build:
|
||||
linux-depends:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: archive
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: rocm
|
||||
- os: linux
|
||||
arch: arm64
|
||||
target: archive
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
- arch: amd64
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
target: jetpack-6
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
@@ -401,79 +511,115 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
SWAP_PATH=/swapfile-mlx
|
||||
SWAP_SIZE_GB=200
|
||||
if [ -f "$SWAP_PATH" ]; then
|
||||
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
|
||||
sudo rm -f "$SWAP_PATH"
|
||||
fi
|
||||
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
|
||||
echo "fallocate unsupported, falling back to dd"
|
||||
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
|
||||
fi
|
||||
sudo chmod 600 "$SWAP_PATH"
|
||||
sudo mkswap "$SWAP_PATH"
|
||||
sudo swapon "$SWAP_PATH"
|
||||
swapon --show
|
||||
free -h
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=inline
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd --ultra -22 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst);
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
needs: [setup-environment, linux-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
steps:
|
||||
@@ -488,9 +634,11 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: type=inline
|
||||
- run: |
|
||||
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
|
||||
@@ -501,10 +649,70 @@ jobs:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/*_LICENSE|lib/ollama/*_NOTICE) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suffix: ['', '-rocm']
|
||||
runs-on: linux
|
||||
@@ -540,7 +748,7 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
needs: [darwin-build, windows-app, linux-build]
|
||||
needs: [darwin-build, windows-app, docker-build-push]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
@@ -558,6 +766,21 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
name: test-llamacpp-update
|
||||
|
||||
# PR validation artifacts from this workflow are intentionally unsigned and not
|
||||
# notarized. They are for llama.cpp update testing only and must not be
|
||||
# published as release artifacts.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LLAMA_CPP_VERSION'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
|
||||
} >>"${GITHUB_OUTPUT}"
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: |
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
./scripts/build_darwin.sh build package
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin.tgz
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
|
||||
# Build payload export stages independently and combine the exported
|
||||
# filesystem artifacts below. This preserves parallelism without Docker
|
||||
# registry credentials or oversized GitHub layer caches.
|
||||
linux-payloads:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: amd64
|
||||
target: publish-llama-server-rocm_v7_2
|
||||
payload: rocm_v7_2
|
||||
- arch: amd64
|
||||
target: publish-llama-server-vulkan
|
||||
payload: vulkan
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack5
|
||||
payload: cuda_jetpack5
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack6
|
||||
payload: cuda_jetpack6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
linux-go:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: publish-go
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux Go payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-go
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
|
||||
# still exposes publish-mlx for a separate MLX-specific workflow.
|
||||
linux-bundles:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: [linux-payloads, linux-go]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: linux-payload-${{ matrix.arch }}-*
|
||||
path: ${{ runner.temp }}/payloads
|
||||
merge-multiple: true
|
||||
- name: Assemble Linux payload tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ runner.temp }}/payloads"
|
||||
arch="${{ matrix.arch }}"
|
||||
dest="dist/linux-${arch}"
|
||||
copy_payload() {
|
||||
local name="$1"
|
||||
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
|
||||
if [ ! -f "${payload}" ]; then
|
||||
echo "missing payload ${payload}"
|
||||
exit 1
|
||||
fi
|
||||
zstd -d <"${payload}" | tar -C "${dest}" -xf -
|
||||
}
|
||||
|
||||
mkdir -p "${dest}"
|
||||
copy_payload go
|
||||
copy_payload cpu
|
||||
copy_payload cuda_v12
|
||||
copy_payload cuda_v13
|
||||
if [ "${arch}" = "amd64" ]; then
|
||||
copy_payload vulkan
|
||||
copy_payload rocm_v7_2
|
||||
else
|
||||
copy_payload cuda_jetpack5
|
||||
copy_payload cuda_jetpack6
|
||||
fi
|
||||
|
||||
./scripts/deduplicate_cuda_libs.sh "${dest}"
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/linux-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"${base}/bin/ollama" \
|
||||
"${base}/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
|
||||
done
|
||||
- name: Create archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "${COMPONENT}" in
|
||||
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/*_LICENSE|lib/ollama/*_NOTICE) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/linux-${{ matrix.arch }}
|
||||
- name: Log archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
echo "${ARCHIVE}"
|
||||
cat "${ARCHIVE}"
|
||||
done
|
||||
- name: Create Linux archives
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
path: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-amd64-rocm.tar.zst
|
||||
path: ollama-linux-amd64-rocm.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack5.tar.zst
|
||||
path: ollama-linux-arm64-jetpack5.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack6.tar.zst
|
||||
path: ollama-linux-arm64-jetpack6.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ')
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
|
||||
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: startsWith(matrix.preset, 'ROCm')
|
||||
name: Install ROCm ${{ matrix.rocm-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
|
||||
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: matrix.preset == 'Vulkan'
|
||||
name: Install Vulkan
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
$version=& gcc -v 2>&1
|
||||
$version=$version -join "`n"
|
||||
echo "gcc is $version"
|
||||
if ($version -notmatch 'clang') {
|
||||
echo "ERROR: GCC must be clang for proper utf16 handling"
|
||||
exit 1
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build Windows binaries and app launchers
|
||||
run: ./scripts/build_windows.ps1 ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-package:
|
||||
runs-on: windows
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: build-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Copy unsigned install script
|
||||
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
|
||||
- name: Log dist contents after download
|
||||
run: gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Collect Go licenses
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
done
|
||||
- name: Build unsigned Windows installer and zips
|
||||
run: ./scripts/build_windows.ps1 deps installer zip
|
||||
- name: Log contents after build
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- name: Verify Windows package outputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/ollama-windows-amd64.zip \
|
||||
dist/ollama-windows-arm64.zip \
|
||||
dist/ollama-windows-amd64-rocm.zip \
|
||||
dist/OllamaSetup.exe \
|
||||
dist/install.ps1
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64.zip
|
||||
path: dist/ollama-windows-amd64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-arm64.zip
|
||||
path: dist/ollama-windows-arm64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64-rocm.zip
|
||||
path: dist/ollama-windows-amd64-rocm.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OllamaSetup.exe
|
||||
path: dist/OllamaSetup.exe
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install.ps1
|
||||
path: dist/install.ps1
|
||||
compression-level: 0
|
||||
@@ -22,7 +22,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
go_mod_changed: ${{ steps.changes.outputs.go_mod_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -37,8 +39,45 @@ jobs:
|
||||
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
|
||||
}
|
||||
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo changed=$(changed \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'x/mlxrunner/xgrammar/native/**' \
|
||||
'x/mlxrunner/xgrammar/native/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo go_mod_changed=$(changed 'go.mod') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -47,23 +86,42 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build/local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib/ollama/llama-server
|
||||
install-go: true
|
||||
- preset: CUDA
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_2
|
||||
superbuild_dir: build/local-superbuild-rocm_v7_2
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
|
||||
- preset: Vulkan
|
||||
container: ubuntu:22.04
|
||||
extra-packages: >
|
||||
mesa-vulkan-drivers vulkan-tools
|
||||
libvulkan1 libvulkan-dev
|
||||
vulkan-sdk cmake ccache g++ make
|
||||
vulkan-sdk spirv-headers cmake ccache g++ make
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build/local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
|
||||
- preset: 'MLX CUDA 13'
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so lib/ollama/mlx_cuda_v13/libollama_xgrammar.so
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
steps:
|
||||
@@ -79,25 +137,44 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
|
||||
$sudo apt-get update
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
|
||||
# MLX requires CMake 3.25+, install from official releases
|
||||
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
|
||||
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
# Export VULKAN_SDK if provided by LunarG package (defensive)
|
||||
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
|
||||
fi
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" --parallel
|
||||
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
|
||||
for f in ${{ matrix.expected_payload }}; do
|
||||
test -e "${{ matrix.superbuild_dir }}/$f"
|
||||
done
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
./ollama --version
|
||||
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
|
||||
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
|
||||
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
|
||||
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
|
||||
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
|
||||
windows:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
@@ -105,9 +182,16 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build\local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib\ollama\llama-server.exe
|
||||
- preset: CUDA
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -118,14 +202,26 @@ jobs:
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_1
|
||||
superbuild_dir: build\local-superbuild-rocm_v7_1
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
|
||||
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
|
||||
- preset: Vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build\local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
|
||||
- preset: 'MLX CUDA 13'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll lib\ollama\mlx_cuda_v13\ollama_xgrammar.dll
|
||||
install-go: true
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -135,6 +231,11 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
@@ -194,6 +295,10 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'MLX CUDA 13'
|
||||
@@ -223,18 +328,46 @@ jobs:
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --parallel --preset "${{ matrix.preset }}"
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
|
||||
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
|
||||
foreach ($f in "${{ matrix.expected_payload }}".Split(' ')) {
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\$f")) {
|
||||
throw "missing $f"
|
||||
}
|
||||
}
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
& ".\ollama.exe" --version
|
||||
& "${{ matrix.superbuild_dir }}\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing llama-quantize.exe"
|
||||
}
|
||||
$installPrefix = Join-Path $env:RUNNER_TEMP "ollama-local"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$installPrefix"
|
||||
& "$installPrefix\bin\ollama.exe" --version
|
||||
& "$installPrefix\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "$installPrefix\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing installed llama-quantize.exe"
|
||||
}
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -242,7 +375,26 @@ jobs:
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
|
||||
|
||||
go_license:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go_mod_changed == 'True'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Verify Go dependency licenses
|
||||
run: |
|
||||
# See cmake/generate_go_license.cmake for special case handling.
|
||||
cmake -S . -B build/go-license \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=linux/amd64;linux/arm64;darwin/amd64;darwin/arm64;windows/amd64;windows/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
|
||||
test:
|
||||
needs: [changes]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
@@ -256,10 +408,22 @@ jobs:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Cache MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/mlx-darwin-release
|
||||
# Key on every payload input so a source-built payload survives pushes.
|
||||
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
|
||||
- name: Prepare MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: .github/scripts/prepare_mlx_darwin.sh
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./app/ui/app
|
||||
run: npm ci
|
||||
@@ -270,22 +434,72 @@ jobs:
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Verify MLX generated files are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
|
||||
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
|
||||
git diff --exit-code -- \
|
||||
x/imagegen/mlx/mlx.h \
|
||||
x/imagegen/mlx/mlx.c \
|
||||
x/mlxrunner/mlx/generated.h \
|
||||
x/mlxrunner/mlx/generated.c \
|
||||
x/mlxrunner/mlx/include/mlx/c
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Verify UI generated types are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: git diff --exit-code -- app/ui/app/codegen/gotypes.gen.ts
|
||||
|
||||
- name: go test
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
# Smoke-run each benchmark once to catch panics and bit rot; this does
|
||||
# not assert timings. -benchtime without -bench is inert.
|
||||
run: go test -count=1 -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: go test app with live updater tag
|
||||
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
race:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly and do not change files
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Cache MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/mlx-darwin-release
|
||||
# Key on every payload input so a source-built payload survives pushes.
|
||||
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
|
||||
- name: Prepare MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: .github/scripts/prepare_mlx_darwin.sh
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
# app/ui embeds app/dist, so the UI has to be built before app/... will
|
||||
# even compile.
|
||||
- name: Build UI
|
||||
working-directory: ./app/ui/app
|
||||
run: |
|
||||
make -f Makefile.sync clean checkout apply-patches sync
|
||||
git diff --compact-summary --exit-code
|
||||
npm ci
|
||||
npm run build
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
@@ -15,3 +15,4 @@ __debug_bin*
|
||||
llama/build
|
||||
llama/vendor
|
||||
/ollama
|
||||
integration/testdata/models/
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Building
|
||||
|
||||
For a full build from the repository root:
|
||||
|
||||
```sh
|
||||
cmake -B build .
|
||||
cmake --build build --parallel 8
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
For quick Go-only iteration against an existing native payload:
|
||||
|
||||
```sh
|
||||
go build .
|
||||
go run . serve
|
||||
```
|
||||
|
||||
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
|
||||
the full development workflow.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
@@ -23,39 +23,37 @@ include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
|
||||
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
set(GGML_CCACHE ON)
|
||||
set(GGML_BACKEND_DL ON)
|
||||
set(GGML_BACKEND_SHARED ON)
|
||||
set(GGML_SCHED_MAX_COPIES 4)
|
||||
|
||||
set(GGML_LLAMAFILE ON)
|
||||
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
|
||||
set(GGML_CUDA_GRAPHS ON)
|
||||
set(GGML_CUDA_FA ON)
|
||||
set(GGML_CUDA_COMPRESSION_MODE default)
|
||||
|
||||
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
# GGML backend for inference is provided by llama-server (built separately via
|
||||
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
|
||||
# The root CMake project is the orchestration entrypoint; backend-specific
|
||||
# build rules live in subprojects under cmake/.
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
elseif(UNIX)
|
||||
set(CMAKE_BUILD_RPATH "$ORIGIN")
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
@@ -64,238 +62,9 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
# Store ggml include paths for use with target_include_directories later.
|
||||
# We avoid global include_directories() to prevent polluting the include path
|
||||
# for other projects like MLX (whose openblas dependency has its own common.h).
|
||||
set(GGML_INCLUDE_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
|
||||
)
|
||||
|
||||
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
|
||||
|
||||
# Define GGML version variables for shared library SOVERSION
|
||||
# These are required by ggml/src/CMakeLists.txt for proper library versioning
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 0)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
set(GGML_CPU ON)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
|
||||
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
|
||||
|
||||
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
|
||||
if(NOT CPU_VARIANTS)
|
||||
set(CPU_VARIANTS "ggml-cpu")
|
||||
endif()
|
||||
|
||||
# Apply ggml include directories to ggml targets only (not globally)
|
||||
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
foreach(variant ${CPU_VARIANTS})
|
||||
if(TARGET ${variant})
|
||||
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ggml-base ${CPU_VARIANTS}
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
)
|
||||
|
||||
check_language(CUDA)
|
||||
if(CMAKE_CUDA_COMPILER)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "native")
|
||||
endif()
|
||||
|
||||
find_package(CUDAToolkit)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
|
||||
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-cuda
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
)
|
||||
endif()
|
||||
|
||||
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
|
||||
CACHE STRING
|
||||
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
|
||||
)
|
||||
|
||||
check_language(HIP)
|
||||
if(CMAKE_HIP_COMPILER)
|
||||
set(HIP_PLATFORM "amd")
|
||||
|
||||
if(NOT AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
|
||||
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
|
||||
endif()
|
||||
|
||||
if(AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
|
||||
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
|
||||
|
||||
install(TARGETS ggml-hip
|
||||
RUNTIME_DEPENDENCY_SET rocm
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET rocm
|
||||
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
|
||||
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
POST_EXCLUDE_REGEXES "system32"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
|
||||
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
|
||||
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
|
||||
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-vulkan
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_INCLUDE_REGEXES vulkan
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(MLX_ENGINE "Enable MLX backend" OFF)
|
||||
if(MLX_ENGINE)
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
|
||||
if(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform)
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||
# Metal backend is only built for arm64, not x86_64
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install CCCL headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
|
||||
else()
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
|
||||
endif()
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
|
||||
@@ -11,109 +11,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-t 4",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"name": "MLX Metal",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_PLATFORM": "amd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"MLX_ENGINE": "ON",
|
||||
"OLLAMA_RUNNER_DIR": "mlx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"inherits": [ "MLX", "CUDA 12" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"inherits": [ "MLX", "CUDA 13" ],
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "86;89;90;90a;100;103;75-virtual;80-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -124,74 +25,9 @@
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"configurePreset": "Default",
|
||||
"targets": [ "ggml-cpu" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"configurePreset": "CUDA",
|
||||
"targets": [ "ggml-cuda" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 11"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 13"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 5"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"configurePreset": "ROCm",
|
||||
"targets": [ "ggml-hip" ]
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 7"
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"targets": [ "ggml-vulkan" ],
|
||||
"configurePreset": "Vulkan"
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 13"
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ARG FLAVOR=${TARGETARCH}
|
||||
|
||||
ARG ROCMVERSION=7.2
|
||||
ARG ROCMVERSION=7.2.1
|
||||
ARG JETPACK5VERSION=r35.4.1
|
||||
ARG JETPACK6VERSION=r36.4.0
|
||||
ARG CMAKEVERSION=3.31.2
|
||||
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
|
||||
FROM scratch AS local-mlx-c
|
||||
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
@@ -37,113 +37,178 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
FROM base AS cpu
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM base AS cuda-12
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
|
||||
FROM base AS cuda-13
|
||||
FROM base AS cuda-13-deps
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
FROM base AS vulkan-deps
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, llama/compat/, or cmake/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
#
|
||||
|
||||
FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
FROM scratch AS publish-llama-server-cpu
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v12
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v13
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM scratch AS publish-llama-server-rocm_v7_2
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-vulkan
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack5
|
||||
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
@@ -154,10 +219,11 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY cmake cmake
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY x/mlxrunner/xgrammar/native x/mlxrunner/xgrammar/native
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_CORE_VERSION .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
RUN go mod download
|
||||
@@ -170,9 +236,15 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
|
||||
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component MLX --strip
|
||||
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
|
||||
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
|
||||
|
||||
FROM scratch AS publish-mlx
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -189,36 +261,71 @@ ENV CGO_CFLAGS="${CGO_CFLAGS}"
|
||||
ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
cmake -S . -B build/go-license \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_MLX_BACKENDS= \
|
||||
&& cmake --build build/go-license --target ollama-go-license
|
||||
|
||||
FROM scratch AS publish-go
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update \
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive /bin /usr/bin
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=archive /lib/ollama /usr/lib/ollama
|
||||
COPY --from=image-archive /lib/ollama /usr/lib/ollama
|
||||
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b10864
|
||||
@@ -1 +0,0 @@
|
||||
v0.31.1
|
||||
@@ -0,0 +1 @@
|
||||
c74db5307cc8ce122f48d97ef951b30578674e7f
|
||||
@@ -1 +1 @@
|
||||
v0.6.0
|
||||
37c26e5755da637255d57ea34b4879196a485301
|
||||
@@ -1,76 +0,0 @@
|
||||
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
|
||||
WORKDIR=llama/vendor
|
||||
FETCH_HEAD=ec98e2002
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " sync Sync with upstream repositories"
|
||||
@echo " checkout Checkout upstream repository"
|
||||
@echo " apply-patches Apply patches to local repository"
|
||||
@echo " format-patches Format patches from local repository"
|
||||
@echo " clean Clean local repository"
|
||||
@echo
|
||||
@echo "Example:"
|
||||
@echo " make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches sync"
|
||||
|
||||
.PHONY: sync
|
||||
sync: llama/build-info.cpp ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal
|
||||
|
||||
llama/build-info.cpp: llama/build-info.cpp.in llama/llama.cpp
|
||||
sed -e 's|@FETCH_HEAD@|$(FETCH_HEAD)|' <$< >$@
|
||||
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal: ml/backend/ggml/ggml
|
||||
go generate ./$(@D)
|
||||
|
||||
.PHONY: llama/llama.cpp
|
||||
llama/llama.cpp: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /) $@
|
||||
|
||||
.PHONY: ml/backend/ggml/ggml
|
||||
ml/backend/ggml/ggml: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /ggml/) $@
|
||||
|
||||
PATCHES=$(wildcard llama/patches/*.patch)
|
||||
PATCHED=$(join $(dir $(PATCHES)), $(addsuffix ed, $(addprefix ., $(notdir $(PATCHES)))))
|
||||
|
||||
.PHONY: apply-patches
|
||||
.NOTPARALLEL:
|
||||
apply-patches: $(PATCHED)
|
||||
|
||||
llama/patches/.%.patched: llama/patches/%.patch
|
||||
@if git -c user.name=nobody -c 'user.email=<>' -C $(WORKDIR) am -3 $(realpath $<); then \
|
||||
touch $@; \
|
||||
else \
|
||||
echo "Patch failed. Resolve any conflicts then continue."; \
|
||||
echo "1. Run 'git -C $(WORKDIR) am --continue'"; \
|
||||
echo "2. Run 'make -f $(lastword $(MAKEFILE_LIST)) format-patches'"; \
|
||||
echo "3. Run 'make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: checkout
|
||||
checkout: $(WORKDIR)
|
||||
git -C $(WORKDIR) fetch
|
||||
git -C $(WORKDIR) checkout -f $(FETCH_HEAD)
|
||||
|
||||
$(WORKDIR):
|
||||
git clone $(UPSTREAM) $(WORKDIR)
|
||||
|
||||
.PHONY: format-patches
|
||||
format-patches: llama/patches
|
||||
git -C $(WORKDIR) format-patch \
|
||||
--no-signature \
|
||||
--no-numbered \
|
||||
--zero-commit \
|
||||
-o $(realpath $<) \
|
||||
$(FETCH_HEAD)
|
||||
|
||||
.PHONY: clean
|
||||
clean: checkout
|
||||
@git -C $(WORKDIR) am --abort || true
|
||||
$(RM) llama/patches/.*.patched
|
||||
|
||||
.PHONY: print-base
|
||||
print-base:
|
||||
@echo $(FETCH_HEAD)
|
||||
@@ -55,7 +55,7 @@ The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `olla
|
||||
ollama
|
||||
```
|
||||
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `claude`, `codex`, `openclaw` and more.
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `Claude Code`, `OpenClaw`, `OpenCode` , `Codex`, `Copilot`, and more.
|
||||
|
||||
### Coding
|
||||
|
||||
@@ -65,7 +65,7 @@ To launch a specific integration:
|
||||
ollama launch claude
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
@@ -77,10 +77,10 @@ ollama launch openclaw
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
|
||||
```
|
||||
ollama run gemma3
|
||||
ollama run gemma4
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma3",
|
||||
"model": "gemma4",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
@@ -113,7 +113,7 @@ pip install ollama
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma3', messages=[
|
||||
response = chat(model='gemma4', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
@@ -132,7 +132,7 @@ npm i ollama
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma3",
|
||||
model: "gemma4",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
|
||||
@@ -68,7 +68,7 @@ type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []MessageParam `json:"messages"`
|
||||
System any `json:"system,omitempty"` // string or []ContentBlock
|
||||
System any `json:"system,omitempty"` // string or []map[string]any (JSON-decoded ContentBlock)
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -78,12 +78,36 @@ type MessagesRequest struct {
|
||||
ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
|
||||
Thinking *ThinkingConfig `json:"thinking,omitempty"`
|
||||
Metadata *Metadata `json:"metadata,omitempty"`
|
||||
OutputConfig *OutputConfig `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
type OutputConfig struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// MessageParam represents a message in the request
|
||||
type MessageParam struct {
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content any `json:"content"` // string or []ContentBlock
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content []ContentBlock `json:"content"` // always []ContentBlock; plain strings are normalized on unmarshal
|
||||
}
|
||||
|
||||
func (m *MessageParam) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Role = raw.Role
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw.Content, &s); err == nil {
|
||||
m.Content = []ContentBlock{{Type: "text", Text: &s}}
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(raw.Content, &m.Content)
|
||||
}
|
||||
|
||||
// ContentBlock represents a content block in a message.
|
||||
@@ -102,9 +126,9 @@ type ContentBlock struct {
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
|
||||
// For tool_use and server_tool_use blocks
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input api.ToolCallFunctionArguments `json:"input,omitzero"`
|
||||
|
||||
// For tool_result and web_search_tool_result blocks
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
@@ -142,7 +166,7 @@ type WebSearchToolResultError struct {
|
||||
|
||||
// ImageSource represents the source of an image
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"` // "base64" or "url"
|
||||
Type string `json:"type"` // "base64"
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -193,8 +217,31 @@ type MessagesResponse struct {
|
||||
|
||||
// Usage contains token usage information
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// UsageFromMetrics separates total prompt tokens into uncached and cache-read counts.
|
||||
func UsageFromMetrics(metrics api.Metrics) Usage {
|
||||
total := max(0, metrics.PromptEvalCount)
|
||||
var cached *int
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
count := min(max(0, *metrics.PromptEvalCachedCount), total)
|
||||
cached = &count
|
||||
}
|
||||
return Usage{
|
||||
InputTokens: total - intValue(cached),
|
||||
CacheReadInputTokens: cached,
|
||||
OutputTokens: metrics.EvalCount,
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// Streaming event types
|
||||
@@ -249,8 +296,9 @@ type MessageDelta struct {
|
||||
|
||||
// DeltaUsage contains cumulative token usage
|
||||
type DeltaUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// MessageStopEvent signals the end of the message
|
||||
@@ -354,9 +402,26 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
}
|
||||
|
||||
var think *api.ThinkValue
|
||||
normalizedEffort := ""
|
||||
if r.OutputConfig != nil {
|
||||
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
|
||||
if normalizedEffort == "xhigh" {
|
||||
normalizedEffort = "high"
|
||||
}
|
||||
}
|
||||
|
||||
if r.Thinking != nil && r.Thinking.Type == "enabled" {
|
||||
think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
if r.Thinking != nil && r.Thinking.Type == "disabled" {
|
||||
think = &api.ThinkValue{Value: false}
|
||||
}
|
||||
if think == nil && r.OutputConfig != nil {
|
||||
switch normalizedEffort {
|
||||
case "high", "medium", "low", "max":
|
||||
think = &api.ThinkValue{Value: normalizedEffort}
|
||||
}
|
||||
}
|
||||
|
||||
stream := r.Stream
|
||||
convertedRequest := &api.ChatRequest{
|
||||
@@ -377,178 +442,136 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
var messages []api.Message
|
||||
role := strings.ToLower(msg.Role)
|
||||
|
||||
switch content := msg.Content.(type) {
|
||||
case string:
|
||||
messages = append(messages, api.Message{Role: role, Content: content})
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
var toolCalls []api.ToolCall
|
||||
var thinking string
|
||||
var toolResults []api.Message
|
||||
textBlocks := 0
|
||||
imageBlocks := 0
|
||||
toolUseBlocks := 0
|
||||
toolResultBlocks := 0
|
||||
serverToolUseBlocks := 0
|
||||
webSearchToolResultBlocks := 0
|
||||
thinkingBlocks := 0
|
||||
unknownBlocks := 0
|
||||
|
||||
case []any:
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
var toolCalls []api.ToolCall
|
||||
var thinking string
|
||||
var toolResults []api.Message
|
||||
textBlocks := 0
|
||||
imageBlocks := 0
|
||||
toolUseBlocks := 0
|
||||
toolResultBlocks := 0
|
||||
serverToolUseBlocks := 0
|
||||
webSearchToolResultBlocks := 0
|
||||
thinkingBlocks := 0
|
||||
unknownBlocks := 0
|
||||
|
||||
for _, block := range content {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: invalid content block format", "role", role)
|
||||
return nil, errors.New("invalid content block format")
|
||||
for _, block := range msg.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if block.Text != nil {
|
||||
textContent.WriteString(*block.Text)
|
||||
}
|
||||
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
|
||||
switch blockType {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
textContent.WriteString(text)
|
||||
}
|
||||
|
||||
case "image":
|
||||
imageBlocks++
|
||||
source, ok := blockMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
|
||||
sourceType, _ := source["type"].(string)
|
||||
if sourceType == "base64" {
|
||||
data, _ := source["data"].(string)
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", sourceType)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", sourceType)
|
||||
}
|
||||
// URL images would need to be fetched - skip for now
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
id, ok := blockMap["id"].(string)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
name, ok := blockMap["name"].(string)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'name' field")
|
||||
}
|
||||
tc := api.ToolCall{
|
||||
ID: id,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
||||
tc.Function.Arguments = mapToArgs(input)
|
||||
}
|
||||
toolCalls = append(toolCalls, tc)
|
||||
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
||||
var resultContent string
|
||||
|
||||
switch c := blockMap["content"].(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
ToolCallID: toolUseID,
|
||||
})
|
||||
|
||||
case "thinking":
|
||||
thinkingBlocks++
|
||||
if t, ok := blockMap["thinking"].(string); ok {
|
||||
thinking = t
|
||||
}
|
||||
|
||||
case "server_tool_use":
|
||||
serverToolUseBlocks++
|
||||
id, _ := blockMap["id"].(string)
|
||||
name, _ := blockMap["name"].(string)
|
||||
tc := api.ToolCall{
|
||||
ID: id,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
||||
tc.Function.Arguments = mapToArgs(input)
|
||||
}
|
||||
toolCalls = append(toolCalls, tc)
|
||||
|
||||
case "web_search_tool_result":
|
||||
webSearchToolResultBlocks++
|
||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: formatWebSearchToolResultContent(blockMap["content"]),
|
||||
ToolCallID: toolUseID,
|
||||
})
|
||||
default:
|
||||
unknownBlocks++
|
||||
case "image":
|
||||
imageBlocks++
|
||||
if block.Source == nil {
|
||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
|
||||
decoded, err := resolveImageSource(block.Source)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: unsupported image source", "role", role, "source_type", block.Source.Type, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
images = append(images, decoded)
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
if block.ID == "" {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
if block.Name == "" {
|
||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'name' field")
|
||||
}
|
||||
toolCalls = append(toolCalls, api.ToolCall{
|
||||
ID: block.ID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: block.Input,
|
||||
},
|
||||
})
|
||||
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
resultContent, resultImages, err := convertToolResultContent(block.Content)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid tool_result content", "role", role, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
Images: resultImages,
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
|
||||
case "thinking":
|
||||
thinkingBlocks++
|
||||
if block.Thinking != nil {
|
||||
thinking = *block.Thinking
|
||||
}
|
||||
|
||||
case "server_tool_use":
|
||||
serverToolUseBlocks++
|
||||
toolCalls = append(toolCalls, api.ToolCall{
|
||||
ID: block.ID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: block.Input,
|
||||
},
|
||||
})
|
||||
|
||||
case "web_search_tool_result":
|
||||
webSearchToolResultBlocks++
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: formatWebSearchToolResultContent(block.Content),
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
default:
|
||||
unknownBlocks++
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
Content: textContent.String(),
|
||||
Images: images,
|
||||
ToolCalls: toolCalls,
|
||||
Thinking: thinking,
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Add tool results as separate messages
|
||||
messages = append(messages, toolResults...)
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(content),
|
||||
"text", textBlocks,
|
||||
"image", imageBlocks,
|
||||
"tool_use", toolUseBlocks,
|
||||
"tool_result", toolResultBlocks,
|
||||
"server_tool_use", serverToolUseBlocks,
|
||||
"web_search_result", webSearchToolResultBlocks,
|
||||
"thinking", thinkingBlocks,
|
||||
"unknown", unknownBlocks,
|
||||
"messages", TraceAPIMessages(messages),
|
||||
)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid message content type: %T", content)
|
||||
}
|
||||
|
||||
if role == "user" && len(toolResults) > 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
Content: textContent.String(),
|
||||
Images: images,
|
||||
ToolCalls: toolCalls,
|
||||
Thinking: thinking,
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Add tool results as separate messages.
|
||||
if role != "user" || len(toolResults) == 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(msg.Content),
|
||||
"text", textBlocks,
|
||||
"image", imageBlocks,
|
||||
"tool_use", toolUseBlocks,
|
||||
"tool_result", toolResultBlocks,
|
||||
"server_tool_use", serverToolUseBlocks,
|
||||
"web_search_result", webSearchToolResultBlocks,
|
||||
"thinking", thinkingBlocks,
|
||||
"unknown", unknownBlocks,
|
||||
"messages", TraceAPIMessages(messages),
|
||||
)
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
@@ -689,10 +712,7 @@ func ToMessagesResponse(id string, r api.ChatResponse) MessagesResponse {
|
||||
Model: r.Model,
|
||||
Content: content,
|
||||
StopReason: stopReason,
|
||||
Usage: Usage{
|
||||
InputTokens: r.Metrics.PromptEvalCount,
|
||||
OutputTokens: r.Metrics.EvalCount,
|
||||
},
|
||||
Usage: UsageFromMetrics(r.Metrics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +742,7 @@ type StreamConverter struct {
|
||||
firstWrite bool
|
||||
contentIndex int
|
||||
inputTokens int
|
||||
cacheReadTokens *int
|
||||
outputTokens int
|
||||
estimatedInputTokens int // Estimated tokens from request (used when actual metrics are 0)
|
||||
thinkingStarted bool
|
||||
@@ -753,8 +774,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
if c.firstWrite {
|
||||
c.firstWrite = false
|
||||
// Use actual metrics if available, otherwise use estimate
|
||||
c.inputTokens = r.Metrics.PromptEvalCount
|
||||
if c.inputTokens == 0 && c.estimatedInputTokens > 0 {
|
||||
usage := UsageFromMetrics(r.Metrics)
|
||||
c.inputTokens = usage.InputTokens
|
||||
c.cacheReadTokens = usage.CacheReadInputTokens
|
||||
if c.inputTokens == 0 && intValue(c.cacheReadTokens) == 0 && c.estimatedInputTokens > 0 {
|
||||
c.inputTokens = c.estimatedInputTokens
|
||||
}
|
||||
|
||||
@@ -769,8 +792,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
Model: c.Model,
|
||||
Content: []ContentBlock{},
|
||||
Usage: Usage{
|
||||
InputTokens: c.inputTokens,
|
||||
OutputTokens: 0,
|
||||
InputTokens: c.inputTokens,
|
||||
CacheReadInputTokens: c.cacheReadTokens,
|
||||
OutputTokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -778,6 +802,18 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
}
|
||||
|
||||
if r.Message.Thinking != "" && !c.thinkingDone {
|
||||
if c.textStarted {
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_stop",
|
||||
Data: ContentBlockStopEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: c.contentIndex,
|
||||
},
|
||||
})
|
||||
c.contentIndex++
|
||||
c.textStarted = false
|
||||
}
|
||||
|
||||
if !c.thinkingStarted {
|
||||
c.thinkingStarted = true
|
||||
events = append(events, StreamEvent{
|
||||
@@ -882,7 +918,6 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_start",
|
||||
Data: ContentBlockStartEvent{
|
||||
@@ -892,7 +927,7 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: map[string]any{},
|
||||
Input: api.NewToolCallFunctionArguments(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -940,8 +975,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
})
|
||||
}
|
||||
|
||||
c.inputTokens = r.Metrics.PromptEvalCount
|
||||
c.outputTokens = r.Metrics.EvalCount
|
||||
usage := UsageFromMetrics(r.Metrics)
|
||||
c.inputTokens = usage.InputTokens
|
||||
c.cacheReadTokens = usage.CacheReadInputTokens
|
||||
c.outputTokens = usage.OutputTokens
|
||||
stopReason := mapStopReason(r.DoneReason, len(c.toolCallsSent) > 0)
|
||||
|
||||
events = append(events, StreamEvent{
|
||||
@@ -952,8 +989,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
StopReason: stopReason,
|
||||
},
|
||||
Usage: DeltaUsage{
|
||||
InputTokens: c.inputTokens,
|
||||
OutputTokens: c.outputTokens,
|
||||
InputTokens: c.inputTokens,
|
||||
CacheReadInputTokens: c.cacheReadTokens,
|
||||
OutputTokens: c.outputTokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -984,20 +1022,76 @@ func GenerateMessageID() string {
|
||||
return generateID("msg")
|
||||
}
|
||||
|
||||
func resolveImageSource(source *ImageSource) (api.ImageData, error) {
|
||||
if source.Type != "base64" {
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", source.Type)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(source.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func convertToolResultContent(content any) (string, []api.ImageData, error) {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return c, nil, nil
|
||||
case []any:
|
||||
var text strings.Builder
|
||||
var images []api.ImageData
|
||||
|
||||
for _, cb := range c {
|
||||
cbMap, ok := cb.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch cbMap["type"] {
|
||||
case "text":
|
||||
if t, ok := cbMap["text"].(string); ok {
|
||||
text.WriteString(t)
|
||||
}
|
||||
case "image":
|
||||
rawSource, ok := cbMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
return "", nil, errors.New("invalid tool_result image source")
|
||||
}
|
||||
|
||||
var source ImageSource
|
||||
if rawType, ok := rawSource["type"].(string); ok {
|
||||
source.Type = rawType
|
||||
}
|
||||
if rawMediaType, ok := rawSource["media_type"].(string); ok {
|
||||
source.MediaType = rawMediaType
|
||||
}
|
||||
if rawData, ok := rawSource["data"].(string); ok {
|
||||
source.Data = rawData
|
||||
}
|
||||
|
||||
img, err := resolveImageSource(&source)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
}
|
||||
|
||||
return text.String(), images, nil
|
||||
default:
|
||||
return "", nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ptr returns a pointer to the given string value
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// mapToArgs converts a map to ToolCallFunctionArguments
|
||||
func mapToArgs(m map[string]any) api.ToolCallFunctionArguments {
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// CountTokensRequest represents an Anthropic count_tokens request
|
||||
type CountTokensRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -1009,7 +1103,7 @@ type CountTokensRequest struct {
|
||||
|
||||
// EstimateInputTokens estimates input tokens from a MessagesRequest (reuses CountTokensRequest logic)
|
||||
func EstimateInputTokens(req MessagesRequest) int {
|
||||
return estimateTokens(CountTokensRequest{
|
||||
return EstimateCountTokens(CountTokensRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
System: req.System,
|
||||
@@ -1023,24 +1117,20 @@ type CountTokensResponse struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
|
||||
// estimateTokens returns a rough estimate of tokens (len/4).
|
||||
// EstimateCountTokens returns a rough estimate of tokens (len/4).
|
||||
// TODO: Replace with actual tokenization via Tokenize API for accuracy.
|
||||
// Current len/4 heuristic is a rough approximation (~4 chars/token average).
|
||||
func estimateTokens(req CountTokensRequest) int {
|
||||
func EstimateCountTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
if req.System != nil {
|
||||
totalLen += countAnyContent(req.System)
|
||||
}
|
||||
totalLen += countAnyContent(req.System)
|
||||
|
||||
// Count messages
|
||||
for _, msg := range req.Messages {
|
||||
// Count role (always present)
|
||||
totalLen += len(msg.Role)
|
||||
// Count content
|
||||
contentLen := countAnyContent(msg.Content)
|
||||
totalLen += contentLen
|
||||
totalLen += countAnyContent(msg.Content)
|
||||
}
|
||||
|
||||
for _, tool := range req.Tools {
|
||||
@@ -1063,12 +1153,25 @@ func countAnyContent(content any) int {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return len(c)
|
||||
case []any:
|
||||
case []ContentBlock:
|
||||
total := 0
|
||||
for _, block := range c {
|
||||
total += countContentBlock(block)
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
total := 0
|
||||
for _, item := range c {
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var block ContentBlock
|
||||
if err := json.Unmarshal(data, &block); err == nil {
|
||||
total += countContentBlock(block)
|
||||
}
|
||||
}
|
||||
return total
|
||||
default:
|
||||
if data, err := json.Marshal(content); err == nil {
|
||||
return len(data)
|
||||
@@ -1077,38 +1180,19 @@ func countAnyContent(content any) int {
|
||||
}
|
||||
}
|
||||
|
||||
func countContentBlock(block any) int {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := block.(string); ok {
|
||||
return len(s)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func countContentBlock(block ContentBlock) int {
|
||||
total := 0
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
total += len(text)
|
||||
if block.Text != nil {
|
||||
total += len(*block.Text)
|
||||
}
|
||||
|
||||
if thinking, ok := blockMap["thinking"].(string); ok {
|
||||
total += len(thinking)
|
||||
if block.Thinking != nil {
|
||||
total += len(*block.Thinking)
|
||||
}
|
||||
|
||||
if blockType == "tool_use" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
if block.Type == "tool_use" || block.Type == "tool_result" {
|
||||
if data, err := json.Marshal(block); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
if blockType == "tool_result" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,10 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -368,6 +372,16 @@ func (c *Client) List(ctx context.Context) (*ListResponse, error) {
|
||||
return &lr, nil
|
||||
}
|
||||
|
||||
// ModelRecommendationsExperimental lists model recommendations from the local
|
||||
// server's experimental recommendations endpoint.
|
||||
func (c *Client) ModelRecommendationsExperimental(ctx context.Context) (*ModelRecommendationsResponse, error) {
|
||||
var resp ModelRecommendationsResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/experimental/model-recommendations", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListRunning lists running models.
|
||||
func (c *Client) ListRunning(ctx context.Context) (*ProcessResponse, error) {
|
||||
var lr ProcessResponse
|
||||
@@ -459,6 +473,26 @@ func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse,
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// WebSearchExperimental searches the web through the local server's
|
||||
// experimental web search endpoint.
|
||||
func (c *Client) WebSearchExperimental(ctx context.Context, req *WebSearchRequest) (*WebSearchResponse, error) {
|
||||
var resp WebSearchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_search", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// WebFetchExperimental fetches web page content through the local server's
|
||||
// experimental web fetch endpoint.
|
||||
func (c *Client) WebFetchExperimental(ctx context.Context, req *WebFetchRequest) (*WebFetchResponse, error) {
|
||||
var resp WebFetchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_fetch", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Signout will signout a client for a local ollama server.
|
||||
func (c *Client) Signout(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/signout", nil, nil)
|
||||
|
||||
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -192,6 +194,35 @@ func TestClientStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamReportsReadErrors(t *testing.T) {
|
||||
client := NewClient(
|
||||
&url.URL{Scheme: "http", Host: "example.com"},
|
||||
&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := failingReader{
|
||||
data: []byte(`{"message":{"content":"partial"}}` + "\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(&body),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
)
|
||||
|
||||
err := client.stream(t.Context(), http.MethodPost, "/api/chat", nil, func([]byte) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected stream read error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -320,3 +351,157 @@ func TestClientDo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebSearchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebSearchResponse{
|
||||
Results: []WebSearchResult{{Title: "Ollama", URL: "https://ollama.com", Content: "models"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama", MaxResults: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_search" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_search", gotPath)
|
||||
}
|
||||
if gotRequest.Query != "ollama" || gotRequest.MaxResults != 3 {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if len(resp.Results) != 1 || resp.Results[0].Title != "Ollama" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
assertError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "unauthorized retains sign in URL",
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var authErr AuthorizationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("error = %T, want AuthorizationError", err)
|
||||
}
|
||||
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
|
||||
t.Fatalf("authorization error = %#v", authErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate limit retains status",
|
||||
status: http.StatusTooManyRequests,
|
||||
body: `{"error":"rate limit exceeded"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var statusErr StatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
t.Fatalf("error = %T, want StatusError", err)
|
||||
}
|
||||
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
|
||||
t.Fatalf("status error = %#v", statusErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
tt.assertError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebFetchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebFetchResponse{
|
||||
Title: "Ollama",
|
||||
Content: "models",
|
||||
Links: []string{"https://ollama.com/library"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebFetchExperimental(t.Context(), &WebFetchRequest{URL: "https://ollama.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_fetch" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_fetch", gotPath)
|
||||
}
|
||||
if gotRequest.URL != "https://ollama.com" {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if resp.Title != "Ollama" || resp.Content != "models" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type failingReader struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *failingReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) > 0 {
|
||||
n := copy(p, r.data)
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
return 0, r.err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsCachedPromptJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count *int
|
||||
want string
|
||||
}{
|
||||
{name: "unreported", want: `{}`},
|
||||
{name: "zero", count: testIntPtr(0), want: `{"prompt_eval_cached_count":0}`},
|
||||
{name: "positive", count: testIntPtr(4), want: `{"prompt_eval_cached_count":4}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(Metrics{PromptEvalCachedCount: tt.count})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != tt.want {
|
||||
t.Errorf("json = %s, want %s", got, tt.want)
|
||||
}
|
||||
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.count == nil {
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
t.Errorf("cached count = %v, want nil", metrics.PromptEvalCachedCount)
|
||||
}
|
||||
} else if metrics.PromptEvalCachedCount == nil || *metrics.PromptEvalCachedCount != *tt.count {
|
||||
t.Errorf("cached count = %v, want %d", metrics.PromptEvalCachedCount, *tt.count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsSummaryCachedPromptTokens(t *testing.T) {
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := os.Stderr
|
||||
os.Stderr = write
|
||||
t.Cleanup(func() { os.Stderr = original })
|
||||
|
||||
(&Metrics{
|
||||
PromptEvalCount: 10,
|
||||
PromptEvalCachedCount: testIntPtr(4),
|
||||
PromptEvalDuration: time.Second,
|
||||
}).Summary()
|
||||
write.Close()
|
||||
os.Stderr = original
|
||||
|
||||
output, err := io.ReadAll(read)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"prompt eval count: 10 token(s)", "prompt eval cached: 4 token(s)", "prompt eval rate: 6.00 tokens/s"} {
|
||||
if !strings.Contains(string(output), want) {
|
||||
t.Errorf("summary missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,20 +127,6 @@ type GenerateRequest struct {
|
||||
// each with an associated log probability. Only applies when Logprobs is true.
|
||||
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Width is the width of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Width int32 `json:"width,omitempty"`
|
||||
|
||||
// Height is the height of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Height int32 `json:"height,omitempty"`
|
||||
|
||||
// Steps is the number of diffusion steps for image generation.
|
||||
// Only used for image generation models.
|
||||
Steps int32 `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest describes a request sent by [Client.Chat].
|
||||
@@ -436,6 +422,7 @@ type ToolProperty struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
||||
@@ -568,12 +555,13 @@ type DebugInfo struct {
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
TotalDuration time.Duration `json:"total_duration,omitempty"`
|
||||
LoadDuration time.Duration `json:"load_duration,omitempty"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
|
||||
EvalCount int `json:"eval_count,omitempty"`
|
||||
EvalDuration time.Duration `json:"eval_duration,omitempty"`
|
||||
TotalDuration time.Duration `json:"total_duration,omitempty"`
|
||||
LoadDuration time.Duration `json:"load_duration,omitempty"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
PromptEvalCachedCount *int `json:"prompt_eval_cached_count,omitempty"`
|
||||
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
|
||||
EvalCount int `json:"eval_count,omitempty"`
|
||||
EvalDuration time.Duration `json:"eval_duration,omitempty"`
|
||||
}
|
||||
|
||||
// Options specified in [GenerateRequest]. If you add a new option here, also
|
||||
@@ -599,12 +587,13 @@ type Options struct {
|
||||
|
||||
// Runner options which must be set when the model is loaded into memory
|
||||
type Runner struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU *int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
DraftNumPredict int `json:"draft_num_predict,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedRequest is the request passed to [Client.Embed].
|
||||
@@ -671,6 +660,9 @@ type CreateRequest struct {
|
||||
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
|
||||
Quantize string `json:"quantize,omitempty"`
|
||||
|
||||
// DraftQuantize is the quantization format for the draft model.
|
||||
DraftQuantize string `json:"draft_quantize,omitempty"`
|
||||
|
||||
// From is the name of the model or file to use as the source.
|
||||
From string `json:"from,omitempty"`
|
||||
|
||||
@@ -680,6 +672,9 @@ type CreateRequest struct {
|
||||
// Files is a map of files include when creating the model.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
|
||||
// DraftFiles is a map of draft model files to include when creating the model.
|
||||
DraftFiles map[string]string `json:"draft_files,omitempty"`
|
||||
|
||||
// Adapters is a map of LoRA adapters to include when creating the model.
|
||||
Adapters map[string]string `json:"adapters,omitempty"`
|
||||
|
||||
@@ -698,8 +693,11 @@ type CreateRequest struct {
|
||||
// Messages is a list of messages added to the model before chat and generation requests.
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
|
||||
// Renderer is the name of the renderer used when constructing a request to the model.
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Parser is the name of the parser used to parse the output of the request.
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
@@ -801,6 +799,50 @@ type ListResponse struct {
|
||||
Models []ListModelResponse `json:"models"`
|
||||
}
|
||||
|
||||
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
|
||||
type ModelRecommendationsResponse struct {
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
Mappings *ModelRecommendationMappings `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMapping defines one app-specific route preference.
|
||||
type ModelRecommendationMapping struct {
|
||||
Model string `json:"model"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMappings defines the app-specific model routes.
|
||||
type ModelRecommendationMappings map[string]ModelRecommendationMapping
|
||||
|
||||
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
|
||||
type ModelRecommendation struct {
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
Thinking *ModelRecommendationThinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationThinking advertises the exact values accepted by
|
||||
// Ollama's think field and the model's default. Values may be booleans for
|
||||
// binary thinking controls or strings for adjustable effort levels.
|
||||
type ModelRecommendationThinking struct {
|
||||
Values []any `json:"values,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns an independent copy.
|
||||
func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *t
|
||||
clone.Values = append([]any(nil), t.Values...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// ProcessResponse is the response from [Client.Process].
|
||||
type ProcessResponse struct {
|
||||
Models []ProcessModelResponse `json:"models"`
|
||||
@@ -808,14 +850,15 @@ type ProcessResponse struct {
|
||||
|
||||
// ListModelResponse is a single model description in [ListResponse].
|
||||
type ListModelResponse struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessModelResponse is a single model description in [ProcessResponse].
|
||||
@@ -844,6 +887,36 @@ type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
}
|
||||
|
||||
// WebSearchRequest is the request for [Client.WebSearchExperimental].
|
||||
type WebSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchResult is a single result from [Client.WebSearchExperimental].
|
||||
type WebSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WebSearchResponse is the response from [Client.WebSearchExperimental].
|
||||
type WebSearchResponse struct {
|
||||
Results []WebSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// WebFetchRequest is the request for [Client.WebFetchExperimental].
|
||||
type WebFetchRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// WebFetchResponse is the response from [Client.WebFetchExperimental].
|
||||
type WebFetchResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the response passed into [GenerateResponseFunc].
|
||||
type GenerateResponse struct {
|
||||
// Model is the model name that generated the response.
|
||||
@@ -884,20 +957,6 @@ type GenerateResponse struct {
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Image contains a base64-encoded generated image.
|
||||
// Only present for image generation models.
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// Completed is the number of completed steps in image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
|
||||
// Total is the total number of steps for image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ModelDetails provides details about a model.
|
||||
@@ -908,6 +967,8 @@ type ModelDetails struct {
|
||||
Families []string `json:"families"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
QuantizationLevel string `json:"quantization_level"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
EmbeddingLength int `json:"embedding_length,omitempty"`
|
||||
}
|
||||
|
||||
// UserResponse provides information about a user.
|
||||
@@ -942,9 +1003,18 @@ func (m *Metrics) Summary() {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval count: %d token(s)\n", m.PromptEvalCount)
|
||||
}
|
||||
|
||||
cached := 0
|
||||
if m.PromptEvalCachedCount != nil {
|
||||
cached = *m.PromptEvalCachedCount
|
||||
}
|
||||
if cached > 0 {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval cached: %d token(s)\n", cached)
|
||||
}
|
||||
|
||||
if m.PromptEvalDuration > 0 {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval duration: %s\n", m.PromptEvalDuration)
|
||||
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(m.PromptEvalCount)/m.PromptEvalDuration.Seconds())
|
||||
uncached := max(0, m.PromptEvalCount-cached)
|
||||
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(uncached)/m.PromptEvalDuration.Seconds())
|
||||
}
|
||||
|
||||
if m.EvalCount > 0 {
|
||||
@@ -1030,14 +1100,25 @@ func (opts *Options) FromMap(m map[string]any) error {
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
val, ok := val.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("option %q must be of type boolean", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&val))
|
||||
} else {
|
||||
case reflect.Int:
|
||||
var i int
|
||||
switch t := val.(type) {
|
||||
case int64:
|
||||
i = int(t)
|
||||
case float64:
|
||||
i = int(t)
|
||||
default:
|
||||
return fmt.Errorf("option %q must be of type integer", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&i))
|
||||
default:
|
||||
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
|
||||
}
|
||||
default:
|
||||
@@ -1063,23 +1144,24 @@ func DefaultOptions() Options {
|
||||
TopP: 0.9,
|
||||
TypicalP: 1.0,
|
||||
RepeatLastN: 64,
|
||||
RepeatPenalty: 1.1,
|
||||
RepeatPenalty: 1.0,
|
||||
PresencePenalty: 0.0,
|
||||
FrequencyPenalty: 0.0,
|
||||
Seed: -1,
|
||||
|
||||
Runner: Runner{
|
||||
// options set when the model is loaded
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
UseMMap: nil,
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
DraftNumPredict: 4,
|
||||
UseMMap: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low")
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
|
||||
type ThinkValue struct {
|
||||
// Value can be a bool or string
|
||||
Value interface{}
|
||||
@@ -1095,7 +1177,7 @@ func (t *ThinkValue) IsValid() bool {
|
||||
case bool:
|
||||
return true
|
||||
case string:
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1129,8 +1211,8 @@ func (t *ThinkValue) Bool() bool {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
// Any string value ("high", "medium", "low") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
// Any string value ("high", "medium", "low", "max") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1168,14 +1250,14 @@ func (t *ThinkValue) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
// Validate string values
|
||||
if s != "high" && s != "medium" && s != "low" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", true, or false)", s)
|
||||
if s != "high" && s != "medium" && s != "low" && s != "max" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
|
||||
}
|
||||
t.Value = s
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", true, or false)")
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
@@ -1278,14 +1360,20 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
|
||||
// TODO: only string slices are supported right now
|
||||
out[key] = vals
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(vals[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bool value %s", vals)
|
||||
}
|
||||
out[key] = &boolVal
|
||||
} else {
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(vals[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int value %s", vals)
|
||||
}
|
||||
out[key] = intVal
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,6 +23,10 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
return props
|
||||
}
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
@@ -168,6 +175,63 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
wantGPU *int
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{}`,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
req: `{ "main_gpu": 0 }`,
|
||||
wantGPU: testIntPtr(0),
|
||||
},
|
||||
{
|
||||
name: "Nonzero",
|
||||
req: `{ "main_gpu": 1 }`,
|
||||
wantGPU: testIntPtr(1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var oMap map[string]any
|
||||
err := json.Unmarshal([]byte(test.req), &oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DefaultOptions()
|
||||
err = opts.FromMap(oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.wantGPU == nil {
|
||||
assert.Nil(t, opts.MainGPU)
|
||||
} else if assert.NotNil(t, opts.MainGPU) {
|
||||
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationDefaultMappingsAreOptions(t *testing.T) {
|
||||
jsonOpts := make(map[string]struct{})
|
||||
for _, field := range reflect.VisibleFields(reflect.TypeOf(Options{})) {
|
||||
jsonTag := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonTag != "" {
|
||||
jsonOpts[jsonTag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, option := range model.GenerationDefaultOptions() {
|
||||
if _, ok := jsonOpts[option]; !ok {
|
||||
t.Fatalf("%s should be defined on api.Options", option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapFormatParams(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
@@ -232,6 +296,12 @@ func TestUseMmapFormatParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUFormatParams(t *testing.T) {
|
||||
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp["main_gpu"])
|
||||
}
|
||||
|
||||
func TestMessage_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -495,6 +565,11 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
input: `{ "think": "low" }`,
|
||||
expectedThinking: &ThinkValue{Value: "low"},
|
||||
},
|
||||
{
|
||||
name: "string_max",
|
||||
input: `{ "think": "max" }`,
|
||||
expectedThinking: &ThinkValue{Value: "max"},
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
input: `{ "think": "invalid" }`,
|
||||
|
||||
@@ -146,19 +146,10 @@ func main() {
|
||||
// Do this after logging is set up so we can debug issues
|
||||
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
|
||||
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
|
||||
if checkAndHandleExistingInstance(urlSchemeRequest) {
|
||||
// The function will exit if it successfully sends to another instance
|
||||
// If we reach here, we're the first/only instance
|
||||
} else {
|
||||
// No existing instance found, handle the URL scheme in this instance
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if u := os.Getenv("OLLAMA_UPDATE_URL"); u != "" {
|
||||
updater.UpdateCheckURLBase = u
|
||||
// This exits after forwarding the request when another instance is
|
||||
// running. First-instance requests are handled later by osRun, after the
|
||||
// Windows UI dependencies are initialized and from the primary thread.
|
||||
checkAndHandleExistingInstance(urlSchemeRequest)
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
@@ -184,7 +175,9 @@ func main() {
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance(startHidden)
|
||||
if !handleExistingInstance(startHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
@@ -209,6 +202,12 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
if devMode {
|
||||
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
|
||||
st.DBPath = dbPath
|
||||
slog.Debug("using development app database", "path", dbPath)
|
||||
}
|
||||
}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
@@ -328,11 +327,11 @@ func main() {
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" {
|
||||
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else {
|
||||
} else if urlSchemeRequest == "" {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
@@ -347,7 +346,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
settings, settingsErr := st.Settings()
|
||||
showOnboarding := shouldShowOnboarding(settings, settingsErr)
|
||||
if settingsErr != nil {
|
||||
slog.Error("failed to load onboarding state", "error", settingsErr)
|
||||
}
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
@@ -359,6 +364,33 @@ func main() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func shouldShowOnboarding(settings store.Settings, err error) bool {
|
||||
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
|
||||
}
|
||||
|
||||
func runInitialWindowsUI(
|
||||
startHidden bool,
|
||||
showOnboarding bool,
|
||||
urlSchemeRequest string,
|
||||
startHiddenFn func(),
|
||||
handleURLFn func(string),
|
||||
showUIFn func(string),
|
||||
) {
|
||||
if urlSchemeRequest != "" {
|
||||
handleURLFn(urlSchemeRequest)
|
||||
return
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenFn()
|
||||
return
|
||||
}
|
||||
if showOnboarding {
|
||||
showUIFn("/")
|
||||
return
|
||||
}
|
||||
showUIFn("/connect")
|
||||
}
|
||||
|
||||
func startHiddenTasks() {
|
||||
// If an upgrade is ready and we're in hidden mode, perform it at startup.
|
||||
// If we're not in hidden mode, we want to start as fast as possible and not
|
||||
@@ -379,7 +411,7 @@ func startHiddenTasks() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil {
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil { //nolint:staticcheck,nolintlint // DoUpgradeAtStartup may always return non-nil on Windows
|
||||
slog.Info("unable to perform upgrade at startup", "error", err)
|
||||
// Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization
|
||||
UpdateAvailable("")
|
||||
@@ -436,7 +468,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
openUI("/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -495,17 +527,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
|
||||
openUI("/")
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isConnect {
|
||||
connect()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Security/Security.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
@@ -16,8 +18,12 @@ enum AppMove
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void killOtherInstances();
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
typedef struct {
|
||||
int pid;
|
||||
int64_t started_at;
|
||||
} AppProcessIdentity;
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
@@ -25,6 +31,7 @@ extern void Restart();
|
||||
// extern void Quit();
|
||||
void StartUI(const char *path);
|
||||
void ShowUI();
|
||||
bool IsOnboardingActive(void);
|
||||
void StopUI();
|
||||
void StartUpdate();
|
||||
void darwinStartHiddenTasks();
|
||||
@@ -38,6 +45,37 @@ void setWindowDelegate(void *window);
|
||||
void showWindow(uintptr_t wndPtr);
|
||||
void hideWindow(uintptr_t wndPtr);
|
||||
void styleWindow(uintptr_t wndPtr);
|
||||
void setWindowResizable(uintptr_t wndPtr, bool resizable);
|
||||
void drag(uintptr_t wndPtr);
|
||||
void doubleClick(uintptr_t wndPtr);
|
||||
void handleConnectURL();
|
||||
bool SetClaudeGatewayInstalled(bool installed, bool restartClaude);
|
||||
bool HasUsedClaudeDesktopIntegration(void);
|
||||
bool RestoreClaudeGatewayForShutdown(void);
|
||||
bool IsClaudeGatewayConfigured(void);
|
||||
bool IsClaudeDesktopInstalled(void);
|
||||
bool IsClaudeDesktopRunning(void);
|
||||
bool IsCodexDesktopInstalled(void);
|
||||
bool IsCodexDesktopConnected(void);
|
||||
bool IsCodexDesktopRunning(void);
|
||||
unsigned long long CodexDesktopRequestCount(void);
|
||||
bool SetCodexDesktopConnected(bool connected, bool restartConfirmed);
|
||||
bool ClaudeGatewayStartFailed(void);
|
||||
bool ClaudeGatewayPortConflict(void);
|
||||
char *ClaudeGatewayErrorMessage(void);
|
||||
int ClaudeGatewayPort(void);
|
||||
void RefreshClaudeProxyMenu(void);
|
||||
void updateClaudeProxyMenu(unsigned long long routed);
|
||||
bool ShowAppsInMenu(void);
|
||||
void SetShowAppsInMenu(bool visible);
|
||||
enum ClaudeInstallResult
|
||||
{
|
||||
ClaudeInstallCancelled,
|
||||
ClaudeInstallerOpened,
|
||||
ClaudeInstallFailed,
|
||||
};
|
||||
enum ClaudeInstallResult installClaudeDesktop(void);
|
||||
enum ClaudeInstallResult installCodexDesktop(void);
|
||||
char *ClaudeDesktopDownloadRequest(char **authorization);
|
||||
bool InstallClaudeDesktopArchive(const char *archivePath);
|
||||
bool InstallCodexDesktopDiskImage(const char *imagePath);
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestShouldShowOnboarding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh install",
|
||||
settings: store.Settings{OnboardingVersion: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed onboarding",
|
||||
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "settings failure",
|
||||
err: errors.New("settings unavailable"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchURLSchemeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
wantConnect bool
|
||||
wantOpen bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
|
||||
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
|
||||
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected := false
|
||||
opened := false
|
||||
err := dispatchURLSchemeRequest(
|
||||
tt.request,
|
||||
func() { connected = true },
|
||||
func() { opened = true },
|
||||
)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if connected != tt.wantConnect {
|
||||
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
|
||||
}
|
||||
if opened != tt.wantOpen {
|
||||
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
urlCalls := 0
|
||||
onboardingCalls := 0
|
||||
openCalls := 0
|
||||
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
true,
|
||||
"ollama://",
|
||||
func() { hiddenCalls++ },
|
||||
func(request string) {
|
||||
urlCalls++
|
||||
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
|
||||
}
|
||||
},
|
||||
func(path string) {
|
||||
onboardingCalls++
|
||||
},
|
||||
)
|
||||
|
||||
if urlCalls != 1 {
|
||||
t.Fatalf("URL handled %d times, want 1", urlCalls)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Errorf("app opened %d times, want 1", openCalls)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
|
||||
}
|
||||
if onboardingCalls != 0 {
|
||||
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIRoutesInteractiveLaunch(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
showOnboarding bool
|
||||
wantPath string
|
||||
}{
|
||||
{name: "fresh install preserves onboarding", showOnboarding: true, wantPath: "/"},
|
||||
{name: "returning launch opens apps", wantPath: "/connect"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
tt.showOnboarding,
|
||||
"",
|
||||
func() { t.Fatal("unexpected hidden startup") },
|
||||
func(string) { t.Fatal("unexpected URL handling") },
|
||||
func(path string) { gotPath = path },
|
||||
)
|
||||
if gotPath != tt.wantPath {
|
||||
t.Fatalf("initial UI path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
func handleExistingInstance(startHidden bool) bool {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func installSymlink() {}
|
||||
@@ -95,11 +96,15 @@ func (ac *appCallbacks) UIRun(path string) {
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
wv.Run("/")
|
||||
return
|
||||
}
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
@@ -110,6 +115,10 @@ func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIOnboarding() bool {
|
||||
return wv.OnboardingActive()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
@@ -126,7 +135,7 @@ func (app *appCallbacks) DoUpdate() {
|
||||
|
||||
app.shutdown()
|
||||
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
if err := updater.DoUpgrade(true); err != nil { //nolint:staticcheck,nolintlint // DoUpgrade may always return non-nil on Windows
|
||||
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
|
||||
}
|
||||
}
|
||||
@@ -138,19 +147,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
handleURLSchemeInCurrentInstance(urlScheme)
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
@@ -161,7 +158,7 @@ func UpdateAvailable(ver string) error {
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
@@ -205,10 +202,8 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
ptr := wv.Run("/")
|
||||
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func(path string) {
|
||||
ptr := wv.Run(path)
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
@@ -225,7 +220,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
})
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
@@ -408,6 +403,8 @@ func hideWindow(ptr unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -432,17 +429,13 @@ func drag(ptr unsafe.Pointer) {}
|
||||
func doubleClick(ptr unsafe.Pointer) {}
|
||||
|
||||
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) {
|
||||
if urlSchemeRequest == "" {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Try to send URL to existing instance using wintray messaging
|
||||
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// No existing instance, we'll handle it ourselves
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
func bindClaudeDesktop(wv webview.WebView) {
|
||||
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionStatus()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionSummary()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopRequestCount", func() uint64 {
|
||||
return claudeDesktopRequestCount()
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopConnected", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopConnection(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult {
|
||||
err := prepareClaudeDesktopConnection()
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("openClaudeDesktop", func() string {
|
||||
if err := openClaudeDesktopApplication(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
wv.Bind("installClaudeDesktop", func() claudeDesktopInstallResult {
|
||||
return requestClaudeDesktopInstall()
|
||||
})
|
||||
|
||||
wv.Bind("applyClaudeDesktopMappings", func(mappings map[string]string, restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := applyClaudeDesktopMappings(mappings, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("resetClaudeDesktopMappings", func(restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := resetClaudeDesktopMappings(restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopAutoMode", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopAutoMode(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("getShowAppsInMenu", func() bool {
|
||||
return getShowAppsInMenu()
|
||||
})
|
||||
|
||||
wv.Bind("setShowAppsInMenu", func(visible bool) {
|
||||
setShowAppsInMenu(visible)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindClaudeDesktop(_ webview.WebView) {}
|
||||
@@ -0,0 +1,252 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaudeDesktopArchiveBytes = 1 << 30
|
||||
maxClaudeDesktopExtractBytes = 2 << 30
|
||||
maxClaudeDesktopArchiveFiles = 100_000
|
||||
claudeDesktopBundleID = "com.anthropic.claudefordesktop"
|
||||
claudeDesktopTeamID = "Q6L2SF6YDW"
|
||||
)
|
||||
|
||||
var errClaudeDesktopDestinationExists = errors.New("Claude Desktop installation destination already exists")
|
||||
|
||||
func claudeDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/Claude.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "Claude.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installClaudeDesktopZip(archivePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("Claude Desktop bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat Claude Desktop archive: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("Claude Desktop archive is not a regular file")
|
||||
}
|
||||
if info.Size() > maxClaudeDesktopArchiveBytes {
|
||||
return "", fmt.Errorf("Claude Desktop archive exceeds %d bytes", maxClaudeDesktopArchiveBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-claude-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create Claude Desktop installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
if err := extractClaudeDesktopZip(archivePath, workDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bundlePath := filepath.Join(workDir, "Claude.app")
|
||||
if err := validateClaudeDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify Claude Desktop signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errClaudeDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check Claude Desktop destination %s: %w", destination, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create Claude Desktop destination: %w", err)
|
||||
}
|
||||
if err := os.Rename(bundlePath, destination); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("move Claude Desktop to %s: %w", destination, err)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install Claude Desktop in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZip(archivePath, destination string) error {
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return errors.New("Claude Desktop archive is empty")
|
||||
}
|
||||
if len(reader.File) > maxClaudeDesktopArchiveFiles {
|
||||
return fmt.Errorf("Claude Desktop archive contains more than %d files", maxClaudeDesktopArchiveFiles)
|
||||
}
|
||||
|
||||
var expanded uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := safeClaudeDesktopArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expanded += file.UncompressedSize64
|
||||
if expanded > maxClaudeDesktopExtractBytes {
|
||||
return fmt.Errorf("Claude Desktop archive expands beyond %d bytes", maxClaudeDesktopExtractBytes)
|
||||
}
|
||||
path := filepath.Join(destination, filepath.FromSlash(clean))
|
||||
switch {
|
||||
case file.FileInfo().IsDir():
|
||||
if err := os.MkdirAll(path, file.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
case file.Mode()&os.ModeSymlink != 0:
|
||||
target, err := readClaudeDesktopZipFile(file, 16<<10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
if err := validateClaudeDesktopSymlink(clean, string(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
if err := os.Symlink(string(target), path); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
case file.Mode().IsRegular():
|
||||
if err := extractClaudeDesktopZipFile(file, path); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Claude Desktop archive contains unsupported file %q", file.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeClaudeDesktopArchivePath(name string) (string, error) {
|
||||
if strings.ContainsRune(name, '\x00') || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unsafe path %q", name)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(name))
|
||||
if clean != "Claude.app" && !strings.HasPrefix(clean, "Claude.app/") {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unexpected path %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopSymlink(name, target string) error {
|
||||
if target == "" || filepath.IsAbs(target) {
|
||||
return fmt.Errorf("Claude Desktop archive contains unsafe symlink %q", name)
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(name), target))
|
||||
resolved = filepath.ToSlash(resolved)
|
||||
if resolved != "Claude.app" && !strings.HasPrefix(resolved, "Claude.app/") {
|
||||
return fmt.Errorf("Claude Desktop archive symlink %q escapes Claude.app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZipFile(file *zip.File, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive file: %w", err)
|
||||
}
|
||||
output, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode().Perm())
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return fmt.Errorf("create Claude Desktop archive file: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
inputErr := input.Close()
|
||||
outputErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("extract Claude Desktop archive file: %w", copyErr)
|
||||
}
|
||||
if inputErr != nil {
|
||||
return fmt.Errorf("close Claude Desktop archive file: %w", inputErr)
|
||||
}
|
||||
if outputErr != nil {
|
||||
return fmt.Errorf("close extracted Claude Desktop file: %w", outputErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readClaudeDesktopZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("archive entry exceeds %d bytes", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("Claude Desktop archive does not contain Claude.app")
|
||||
}
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", "Claude")
|
||||
info, err = os.Stat(executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Claude Desktop executable is missing: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
|
||||
return errors.New("Claude Desktop executable is not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyClaudeDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+claudeDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+claudeDesktopTeamID) {
|
||||
return fmt.Errorf("unexpected Claude Desktop signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallClaudeDesktopZip(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/": {directory: true},
|
||||
"Claude.app/Contents/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/Contents/Resources/": {directory: true},
|
||||
"Claude.app/Contents/Resources/link": {body: "../MacOS/Claude", mode: os.ModeSymlink | 0o777},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
var verified string
|
||||
installed, err := installClaudeDesktopZip(archive, []string{destination}, func(bundle string) error {
|
||||
verified = bundle
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination || verified == "" {
|
||||
t.Fatalf("installed = %q, verified = %q", installed, verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "Claude"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed Claude executable is not executable")
|
||||
}
|
||||
if target, err := os.Readlink(filepath.Join(installed, "Contents", "Resources", "link")); err != nil || target != "../MacOS/Claude" {
|
||||
t.Fatalf("symlink target = %q, err = %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRejectsUnsafeArchives(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
entries map[string]claudeDesktopTestZipEntry
|
||||
}{
|
||||
{name: "path traversal", entries: map[string]claudeDesktopTestZipEntry{"../Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755}}},
|
||||
{name: "unexpected root", entries: map[string]claudeDesktopTestZipEntry{"README": {body: "nope", mode: 0o644}}},
|
||||
{name: "escaping symlink", entries: map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/escape": {body: "../../outside", mode: os.ModeSymlink | 0o777},
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, test.entries)
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("installClaudeDesktopZip succeeded")
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unsafe archive created destination: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipVerifiesBeforeMove(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipDoesNotOverwrite(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); !errors.Is(err, errClaudeDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRealArchive(t *testing.T) {
|
||||
archive := os.Getenv("OLLAMA_TEST_CLAUDE_DESKTOP_ZIP")
|
||||
if archive == "" {
|
||||
t.Skip("set OLLAMA_TEST_CLAUDE_DESKTOP_ZIP to a downloaded Claude Desktop ZIP")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
installed, err := installClaudeDesktopZip(
|
||||
archive,
|
||||
[]string{destination},
|
||||
verifyClaudeDesktopBundle,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
type claudeDesktopTestZipEntry struct {
|
||||
body string
|
||||
mode os.FileMode
|
||||
directory bool
|
||||
}
|
||||
|
||||
func writeClaudeDesktopTestZip(t *testing.T, entries map[string]claudeDesktopTestZipEntry) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "Claude.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, entry := range entries {
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
if entry.directory {
|
||||
header.SetMode(os.ModeDir | 0o755)
|
||||
} else {
|
||||
header.SetMode(entry.mode)
|
||||
}
|
||||
item, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := item.Write([]byte(entry.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSafeClaudeDesktopArchivePath(t *testing.T) {
|
||||
for _, name := range []string{"Claude.app", "Claude.app/Contents/MacOS/Claude"} {
|
||||
if got, err := safeClaudeDesktopArchivePath(name); err != nil || got != strings.TrimSuffix(name, "/") {
|
||||
t.Fatalf("safeClaudeDesktopArchivePath(%q) = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/internal/proxy"
|
||||
|
||||
type claudeDesktopInstallResult string
|
||||
|
||||
const (
|
||||
claudeDesktopInstallCancelled claudeDesktopInstallResult = "cancelled"
|
||||
claudeDesktopInstallerOpened claudeDesktopInstallResult = "opened"
|
||||
claudeDesktopInstallFailed claudeDesktopInstallResult = "failed"
|
||||
)
|
||||
|
||||
type claudeDesktopStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Used bool `json:"used"`
|
||||
Installed bool `json:"installed"`
|
||||
Configured bool `json:"configured"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
StartFailed bool `json:"startFailed"`
|
||||
PortConflict bool `json:"portConflict"`
|
||||
GatewayPort int `json:"gatewayPort,omitempty"`
|
||||
RoutedRequests uint64 `json:"routedRequests"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
ModelSource string `json:"modelSource,omitempty"`
|
||||
Models []claudeDesktopModelStatus `json:"models,omitempty"`
|
||||
Mappings []claudeDesktopMappingStatus `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopMappingStatus struct {
|
||||
RouteID string `json:"routeId"`
|
||||
RouteName string `json:"routeName"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopModelStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cloud bool `json:"cloud"`
|
||||
Selected bool `json:"selected"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
Availability proxy.ClaudeDesktopAvailability `json:"availability"`
|
||||
Reason proxy.ClaudeDesktopAccessReason `json:"reason,omitempty"`
|
||||
RequiredPlan string `json:"requiredPlan,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopActionResult struct {
|
||||
Status claudeDesktopStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
MappingsApplied bool `json:"mappingsApplied,omitempty"`
|
||||
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
func codexDesktopModelRefreshError(settings codexDesktopModelsSettings) string {
|
||||
if len(settings.Selected) > 0 {
|
||||
return "Couldn’t refresh available models. Your saved models are unchanged."
|
||||
}
|
||||
return "Couldn’t refresh available models. Try again."
|
||||
}
|
||||
|
||||
func bindCodexDesktop(wv webview.WebView) {
|
||||
wv.Bind("markCodexDesktopIntegrationUsed", func() string {
|
||||
if err := markCodexDesktopIntegrationUsed(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
wv.Bind("getCodexDesktopStatus", func() codexDesktopStatus {
|
||||
return getCodexDesktopStatus()
|
||||
})
|
||||
wv.Bind("getCodexDesktopRequestCount", func() uint64 {
|
||||
return codexDesktop.OllamaRequestCount()
|
||||
})
|
||||
wv.Bind("setCodexDesktopConnected", func(enabled, restartConfirmed bool) codexDesktopActionResult {
|
||||
err := setCodexDesktopConnection(enabled, restartConfirmed)
|
||||
result := codexDesktopActionResult{Status: getCodexDesktopStatus()}
|
||||
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
result.RestartConfirmationRequired = true
|
||||
} else if err != nil {
|
||||
result.Error = err.Error()
|
||||
slog.Warn("failed to change ChatGPT integration from Settings", "connected", enabled, "error", err)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("installCodexDesktop", func() codexDesktopInstallResult {
|
||||
return requestCodexDesktopInstall()
|
||||
})
|
||||
wv.Bind("getCodexDesktopModelsSettings", func() codexDesktopModelsSettingsResult {
|
||||
settings, err := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if err != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models", "error", err)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("applyCodexDesktopModels", func(models []string, restartConfirmed bool) codexDesktopModelsSettingsResult {
|
||||
err := applyCodexDesktopModels(models, restartConfirmed)
|
||||
settings, statusErr := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
result.RestartConfirmationRequired = true
|
||||
} else if err != nil {
|
||||
result.Error = err.Error()
|
||||
} else if statusErr != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models after applying settings", "error", statusErr)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("resetCodexDesktopModels", func() codexDesktopModelsSettingsResult {
|
||||
err := resetCodexDesktopModels()
|
||||
settings, statusErr := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
} else if statusErr != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models after resetting settings", "error", statusErr)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindCodexDesktop(_ webview.WebView) {}
|
||||
@@ -0,0 +1,212 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCodexDesktopDiskImageBytes = 2 << 30
|
||||
codexDesktopBundleID = "com.openai.codex"
|
||||
codexDesktopTeamID = "2DC432GLL2"
|
||||
)
|
||||
|
||||
var errCodexDesktopDestinationExists = errors.New("ChatGPT installation destination already exists")
|
||||
|
||||
func codexDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/ChatGPT.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "ChatGPT.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installCodexDesktopDiskImage(imagePath string, destinations []string, verify func(string) error) (installedPath string, err error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("ChatGPT installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("ChatGPT bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(imagePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat ChatGPT disk image: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("ChatGPT disk image is not a regular file")
|
||||
}
|
||||
if info.Size() > maxCodexDesktopDiskImageBytes {
|
||||
return "", fmt.Errorf("ChatGPT disk image exceeds %d bytes", maxCodexDesktopDiskImageBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-chatgpt-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create ChatGPT installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
mountPath := filepath.Join(workDir, "volume")
|
||||
if err := os.Mkdir(mountPath, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create ChatGPT mount point: %w", err)
|
||||
}
|
||||
|
||||
output, err := exec.Command(
|
||||
"/usr/bin/hdiutil",
|
||||
"attach",
|
||||
"-nobrowse",
|
||||
"-readonly",
|
||||
"-mountpoint",
|
||||
mountPath,
|
||||
imagePath,
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mount ChatGPT disk image: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
defer func() {
|
||||
detachOutput, detachErr := exec.Command("/usr/bin/hdiutil", "detach", mountPath).CombinedOutput()
|
||||
if detachErr == nil {
|
||||
return
|
||||
}
|
||||
forceOutput, forceErr := exec.Command("/usr/bin/hdiutil", "detach", "-force", mountPath).CombinedOutput()
|
||||
if forceErr != nil && err == nil {
|
||||
err = fmt.Errorf(
|
||||
"unmount ChatGPT disk image: %v: %s; force detach: %v: %s",
|
||||
detachErr,
|
||||
strings.TrimSpace(string(detachOutput)),
|
||||
forceErr,
|
||||
strings.TrimSpace(string(forceOutput)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
bundlePath, err := codexDesktopBundleOnVolume(mountPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return installCodexDesktopBundle(bundlePath, destinations, verify)
|
||||
}
|
||||
|
||||
func codexDesktopBundleOnVolume(mountPath string) (string, error) {
|
||||
for _, name := range []string{"ChatGPT.app", "Codex.app"} {
|
||||
bundlePath := filepath.Join(mountPath, name)
|
||||
info, err := os.Lstat(bundlePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect ChatGPT bundle: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", fmt.Errorf("ChatGPT disk image contains an invalid %s", name)
|
||||
}
|
||||
return bundlePath, nil
|
||||
}
|
||||
return "", errors.New("ChatGPT disk image does not contain ChatGPT.app")
|
||||
}
|
||||
|
||||
func installCodexDesktopBundle(bundlePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if err := validateCodexDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify ChatGPT signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errCodexDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check ChatGPT destination %s: %w", destination, err)
|
||||
}
|
||||
parent := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create ChatGPT destination: %w", err)
|
||||
}
|
||||
stageDir, err := os.MkdirTemp(parent, ".ollama-chatgpt-install-")
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create staged ChatGPT destination: %w", err)
|
||||
}
|
||||
stagedBundle := filepath.Join(stageDir, "ChatGPT.app")
|
||||
copyOutput, copyErr := exec.Command("/usr/bin/ditto", bundlePath, stagedBundle).CombinedOutput()
|
||||
if copyErr == nil {
|
||||
copyErr = validateCodexDesktopBundle(stagedBundle)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = verify(stagedBundle)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = os.Rename(stagedBundle, destination)
|
||||
}
|
||||
removeErr := os.RemoveAll(stageDir)
|
||||
if copyErr != nil {
|
||||
if errors.Is(copyErr, os.ErrPermission) {
|
||||
permissionErr = copyErr
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("install ChatGPT in %s: %w: %s", parent, copyErr, strings.TrimSpace(string(copyOutput)))
|
||||
}
|
||||
if removeErr != nil {
|
||||
return "", fmt.Errorf("remove staged ChatGPT destination: %w", removeErr)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install ChatGPT in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("ChatGPT installation destination is required")
|
||||
}
|
||||
|
||||
func validateCodexDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("ChatGPT disk image does not contain a valid app bundle")
|
||||
}
|
||||
for _, executableName := range []string{"ChatGPT", "Codex"} {
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", executableName)
|
||||
info, err = os.Stat(executable)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect ChatGPT executable: %w", err)
|
||||
}
|
||||
if info.Mode().IsRegular() && info.Mode()&0o111 != 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("ChatGPT executable is not executable")
|
||||
}
|
||||
return errors.New("ChatGPT executable is missing")
|
||||
}
|
||||
|
||||
func verifyCodexDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+codexDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+codexDesktopTeamID) {
|
||||
return errors.New("unexpected ChatGPT signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallCodexDesktopBundle(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
verified := 0
|
||||
installed, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error {
|
||||
verified++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
if verified != 2 {
|
||||
t.Fatalf("signature verification count = %d, want 2", verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "ChatGPT"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed ChatGPT executable is not executable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleAcceptsCodexNamedSource(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "Codex.app", "Codex")
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, "Contents", "MacOS", "Codex")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleVerifiesBeforeCopy(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleDoesNotOverwrite(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleDoesNotOverwriteBrokenSymlink(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
if err := os.Symlink(filepath.Join(t.TempDir(), "missing"), destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopBundleOnVolumeRejectsSymlink(t *testing.T) {
|
||||
volume := t.TempDir()
|
||||
target := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
if err := os.Symlink(target, filepath.Join(volume, "ChatGPT.app")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := codexDesktopBundleOnVolume(volume); err == nil {
|
||||
t.Fatal("codexDesktopBundleOnVolume accepted a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopDiskImageRealArchive(t *testing.T) {
|
||||
image := os.Getenv("OLLAMA_TEST_CODEX_DESKTOP_DMG")
|
||||
if image == "" {
|
||||
t.Skip("set OLLAMA_TEST_CODEX_DESKTOP_DMG to the official ChatGPT DMG")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
installed, err := installCodexDesktopDiskImage(image, []string{destination}, verifyCodexDesktopBundle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexDesktopTestBundle(t *testing.T, appName, executableName string) string {
|
||||
t.Helper()
|
||||
bundle := filepath.Join(t.TempDir(), appName)
|
||||
executable := filepath.Join(bundle, "Contents", "MacOS", executableName)
|
||||
if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -24,11 +25,21 @@ import (
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1360
|
||||
defaultWindowHeight = 960
|
||||
onboardingWindowWidth = 900
|
||||
onboardingWindowHeight = 660
|
||||
minimumWindowWidth = onboardingWindowWidth
|
||||
minimumWindowHeight = onboardingWindowHeight
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
onboarding atomic.Bool
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
@@ -88,85 +99,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
// Keep Edge WebView2 scrollbars aligned with the system theme.
|
||||
function updateScrollbarStyles() {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'scrollbar-style';
|
||||
|
||||
if (isDark) {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
} else {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('load', updateScrollbarStyles);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
||||
`
|
||||
}
|
||||
// on windows make ctrl+n open new chat
|
||||
@@ -187,15 +151,32 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
`
|
||||
}
|
||||
|
||||
init += `
|
||||
init += fmt.Sprintf(`
|
||||
window.OLLAMA_PLATFORM = %q;
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`
|
||||
`, runtime.GOOS)
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (
|
||||
e.key === '+' || e.key === '=' || e.key === '-' ||
|
||||
e.key === '_' || e.key === '0' ||
|
||||
e.code === 'NumpadAdd' || e.code === 'NumpadSubtract'
|
||||
);
|
||||
|
||||
// Keep fixed-scale onboarding and apps pages at their intended size.
|
||||
const isFixedScalePage =
|
||||
window.location.pathname === '/onboarding' ||
|
||||
window.location.pathname === '/connect';
|
||||
if (isFixedScalePage && isZoomShortcut) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
@@ -237,10 +218,42 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("activateOllama", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
bindClaudeDesktop(wv)
|
||||
bindCodexDesktop(wv)
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("setOnboardingWindow", func(enabled bool) {
|
||||
w.onboarding.Store(enabled)
|
||||
wv.Dispatch(func() {
|
||||
if enabled {
|
||||
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
|
||||
setOnboardingWindowStyle(wv.Window(), true)
|
||||
return
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to restore window size", "error", err)
|
||||
} else if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
setOnboardingWindowStyle(wv.Window(), false)
|
||||
})
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
@@ -450,18 +463,18 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to get window size", "error", err)
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
} else {
|
||||
wv.SetSize(800, 600, webview.HintNone)
|
||||
if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
@@ -476,6 +489,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.onboarding.Store(false)
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
@@ -489,6 +503,10 @@ func (w *Webview) Terminate() {
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) OnboardingActive() bool {
|
||||
return w.onboarding.Load()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -143,13 +143,13 @@ func utf16ptr(utf16 []uint16) *uint16 {
|
||||
|
||||
func utf16slice(ptr *uint16) []uint16 { //nolint:unused
|
||||
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
|
||||
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice := *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
i := 0
|
||||
for slice[len(slice)-1] != 0 {
|
||||
i++
|
||||
}
|
||||
hdr.Len = i
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice = *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
return slice
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#define MyAppPublisher "Ollama"
|
||||
#define MyAppURL "https://ollama.com/"
|
||||
#define MyAppExeName "ollama app.exe"
|
||||
#define LlamaServerExeName "llama-server.exe"
|
||||
#define MyIcon ".\assets\app.ico"
|
||||
|
||||
[Setup]
|
||||
@@ -90,9 +91,8 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,9 +103,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -118,12 +120,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -131,6 +127,7 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
|
||||
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
|
||||
; HACK! need to give the server and app enough time to exit
|
||||
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
|
||||
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||
@@ -184,46 +181,6 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
@@ -370,5 +327,8 @@ procedure TaskKill(FileName: String);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileName <> '{#LlamaServerExeName}' then begin
|
||||
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
@@ -83,6 +83,29 @@ func resolvePath(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func ollamaServeArgs(args []string) bool {
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.Trim(filepath.Base(args[0]), `"`) {
|
||||
case "ollama", "ollama.exe":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rawArg := range args[1:] {
|
||||
arg := strings.Trim(rawArg, `"`)
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
return arg == "serve" || arg == "start"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanup checks the pid file for a running ollama process
|
||||
// and shuts it down gracefully if it is running
|
||||
func cleanup() error {
|
||||
|
||||
@@ -205,6 +205,63 @@ func TestServerCmdCloudSettingEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaServeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "system ollama serve",
|
||||
args: []string{"ollama", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "relative path ollama serve",
|
||||
args: []string{"./ollama", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "serve after other flags",
|
||||
args: []string{"./ollama", "--verbose", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "start alias",
|
||||
args: []string{"ollama", "start"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "launch command",
|
||||
args: []string{"ollama", "launch", "opencode"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "run command with model named serve",
|
||||
args: []string{"ollama", "run", "serve"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "launch command with serve in passthrough args",
|
||||
args: []string{"ollama", "launch", "codex", "--", "-p", "serve"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different executable",
|
||||
args: []string{"go", "run", "serve"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ollamaServeArgs(tt.args); got != tt.want {
|
||||
t.Fatalf("ollamaServeArgs(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInferenceInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -308,7 +365,7 @@ time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not f
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
info, err := GetInferenceInfo(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -46,7 +46,17 @@ func terminated(pid int) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// reapServers kills all ollama processes except our own
|
||||
func ollamaServeProcess(pid int) bool {
|
||||
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output()
|
||||
if err != nil {
|
||||
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return ollamaServeArgs(strings.Fields(strings.TrimSpace(string(output))))
|
||||
}
|
||||
|
||||
// reapServers kills external ollama serve processes except our own.
|
||||
func reapServers() error {
|
||||
// Get our own PID to avoid killing ourselves
|
||||
currentPID := os.Getpid()
|
||||
@@ -82,6 +92,9 @@ func reapServers() error {
|
||||
if pid == currentPID {
|
||||
continue
|
||||
}
|
||||
if !ollamaServeProcess(pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
|
||||
@@ -101,7 +101,29 @@ func terminated(pid int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// reapServers kills all ollama processes except our own
|
||||
func ollamaServeProcess(pid int) bool {
|
||||
cmd := exec.Command("wmic", "process", "where", fmt.Sprintf("ProcessId=%d", pid), "get", "CommandLine", "/value")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
commandLine, ok := strings.CutPrefix(line, "CommandLine=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
return ollamaServeArgs(strings.Fields(strings.ToLower(commandLine)))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// reapServers kills external ollama serve processes except our own.
|
||||
func reapServers() error {
|
||||
// Get current process ID to avoid killing ourselves
|
||||
currentPID := os.Getpid()
|
||||
@@ -138,8 +160,11 @@ func reapServers() error {
|
||||
if pid == currentPID {
|
||||
continue
|
||||
}
|
||||
if !ollamaServeProcess(pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 15
|
||||
const currentSchemaVersion = 19
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,11 +82,15 @@ func (db *database) init() error {
|
||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
selected_model TEXT NOT NULL DEFAULT '',
|
||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'chat',
|
||||
onboarding_version INTEGER NOT NULL DEFAULT 0,
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
remote TEXT NOT NULL DEFAULT '', -- deprecated
|
||||
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
codex_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -264,6 +268,29 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v14 to v15: %w", err)
|
||||
}
|
||||
version = 15
|
||||
case 15:
|
||||
// add last_home_view column to settings table
|
||||
if err := db.migrateV15ToV16(); err != nil {
|
||||
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||
}
|
||||
version = 16
|
||||
case 16:
|
||||
// Existing users should not be shown onboarding after an upgrade.
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return fmt.Errorf("migrate v16 to v17: %w", err)
|
||||
}
|
||||
version = 17
|
||||
case 17:
|
||||
// Remember that Claude Desktop has been connected at least once.
|
||||
if err := db.migrateV17ToV18(); err != nil {
|
||||
return fmt.Errorf("migrate v17 to v18: %w", err)
|
||||
}
|
||||
version = 18
|
||||
case 18:
|
||||
if err := db.migrateV18ToV19(); err != nil {
|
||||
return fmt.Errorf("migrate v18 to v19: %w", err)
|
||||
}
|
||||
version = 19
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -518,6 +545,63 @@ func (db *database) migrateV14ToV15() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV15ToV16 adds the last_home_view column to the settings table
|
||||
func (db *database) migrateV15ToV16() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add last_home_view column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 16`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
|
||||
// zero for genuinely new installs, while all existing rows are marked complete
|
||||
// and moved off the retired launch home view.
|
||||
func (db *database) migrateV16ToV17() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add onboarding_version column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete onboarding for existing users: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV17ToV18 adds durable Claude Desktop integration history.
|
||||
func (db *database) migrateV17ToV18() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN claude_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add claude_desktop_used column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 18`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV18ToV19 records successful ChatGPT integration use.
|
||||
func (db *database) migrateV18ToV19() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN codex_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add codex_desktop_used column: %w", err)
|
||||
}
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 19`)
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1166,9 +1250,9 @@ func (db *database) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level, auto_update_enabled
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used, codex_desktop_used
|
||||
FROM settings
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed, &s.CodexDesktopUsed)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1177,16 +1261,26 @@ func (db *database) getSettings() (Settings, error) {
|
||||
}
|
||||
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||
if lastHomeView != "chat" {
|
||||
lastHomeView = "chat"
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) markCodexDesktopUsed() error {
|
||||
_, err := db.conn.Exec(`UPDATE settings SET codex_desktop_used = 1`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *database) isCloudSettingMigrated() (bool, error) {
|
||||
var migrated bool
|
||||
err := db.conn.QueryRow("SELECT cloud_setting_migrated FROM settings").Scan(&migrated)
|
||||
|
||||
@@ -135,6 +135,127 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN last_home_view;
|
||||
UPDATE settings SET schema_version = 15;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v15 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v15 to v16 failed: %v", err)
|
||||
}
|
||||
|
||||
var lastHomeView string
|
||||
if err := db.conn.QueryRow("SELECT last_home_view FROM settings").Scan(&lastHomeView); err != nil {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "chat" {
|
||||
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get schema version: %v", err)
|
||||
}
|
||||
if version != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
|
||||
t.Run("fresh installs need onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing installs skip onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN onboarding_version;
|
||||
UPDATE settings SET schema_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v16 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v16 to v17 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedDefaultsAndMigration(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "claude-history.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected fresh installs to have no Claude Desktop history")
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN claude_desktop_used;
|
||||
UPDATE settings SET schema_version = 17;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v17 settings row: %v", err)
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v17 to v18 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err = db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected existing installs to start with no inferred Claude Desktop history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -442,3 +563,38 @@ func loadV2Schema(t *testing.T, dbPath string) *database {
|
||||
|
||||
return &database{conn: conn}
|
||||
}
|
||||
|
||||
func TestCodexDesktopUsedMigration(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "codex-intro.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.CodexDesktopUsed {
|
||||
t.Fatal("expected fresh installs to have no ChatGPT intro acknowledgment")
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN codex_desktop_used;
|
||||
UPDATE settings SET schema_version = 18;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v18 settings row: %v", err)
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v18 to v19 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err = db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated settings: %v", err)
|
||||
}
|
||||
if settings.CodexDesktopUsed {
|
||||
t.Fatal("expected existing installs to start with no inferred ChatGPT intro acknowledgment")
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
|
||||
t.Error("expected has completed first run to be true after migration")
|
||||
}
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != CurrentOnboardingVersion {
|
||||
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
// Verify migration is marked as complete
|
||||
migrated, err := s.db.isConfigMigrated()
|
||||
if err != nil {
|
||||
|
||||
@@ -167,10 +167,26 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView is retained for settings compatibility and resolves to chat.
|
||||
LastHomeView string
|
||||
|
||||
// OnboardingVersion stores the latest onboarding flow the user has completed.
|
||||
OnboardingVersion int
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
|
||||
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
|
||||
ClaudeDesktopUsed bool
|
||||
|
||||
// CodexDesktopUsed records whether ChatGPT has successfully connected through Ollama.
|
||||
// Only MarkCodexDesktopUsed updates it; SetSettings preserves the stored value.
|
||||
CodexDesktopUsed bool
|
||||
}
|
||||
|
||||
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
|
||||
const CurrentOnboardingVersion = 1
|
||||
|
||||
type Store struct {
|
||||
// DBPath allows overriding the default database path (mainly for testing)
|
||||
DBPath string
|
||||
@@ -331,6 +347,16 @@ func (s *Store) migrateFromConfig(database *database) error {
|
||||
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
|
||||
return fmt.Errorf("migrate first time run: %w", err)
|
||||
}
|
||||
if hasCompleted {
|
||||
settings, err := database.getSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read settings for onboarding migration: %w", err)
|
||||
}
|
||||
settings.OnboardingVersion = CurrentOnboardingVersion
|
||||
if err := database.setSettings(settings); err != nil {
|
||||
return fmt.Errorf("migrate onboarding completion: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
|
||||
|
||||
// Mark as migrated
|
||||
@@ -389,6 +415,10 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "chat"
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
@@ -400,6 +430,13 @@ func (s *Store) SetSettings(settings Settings) error {
|
||||
return s.db.setSettings(settings)
|
||||
}
|
||||
|
||||
func (s *Store) MarkCodexDesktopUsed() error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.markCodexDesktopUsed()
|
||||
}
|
||||
|
||||
func (s *Store) Chats() ([]Chat, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -81,6 +81,62 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is chat", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("window size", func(t *testing.T) {
|
||||
if err := s.SetWindowSize(1024, 768); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -171,6 +227,113 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnboardingVersionRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
settings.OnboardingVersion = 1
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to be false by default")
|
||||
}
|
||||
|
||||
settings.ClaudeDesktopUsed = true
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopUsedPreservedBySettings(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Browser = true
|
||||
settings.ClaudeDesktopUsed = true
|
||||
settings.CodexDesktopUsed = true
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.CodexDesktopUsed {
|
||||
t.Fatal("ordinary settings save acknowledged the intro")
|
||||
}
|
||||
settings.CodexDesktopUsed = false
|
||||
if saved != settings {
|
||||
t.Fatal("ordinary settings save lost unrelated settings")
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
if err := s.MarkCodexDesktopUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
saved, err = s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := settings
|
||||
want.CodexDesktopUsed = true
|
||||
if saved != want {
|
||||
t.Fatal("acknowledgment did not preserve unrelated settings")
|
||||
}
|
||||
|
||||
settings.Browser = false
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err = s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want.Browser = false
|
||||
if saved != want {
|
||||
t.Fatal("stale settings save lost acknowledgment or the requested setting")
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -563,6 +563,10 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
|
||||
return b.state.Data, pageText, nil
|
||||
}
|
||||
|
||||
if !allowedDirectURL(ctx, url) {
|
||||
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
// Page not in cache, need to crawl it
|
||||
if b.crawlPage == nil {
|
||||
b.crawlPage = &BrowserCrawler{}
|
||||
|
||||
@@ -65,6 +65,27 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
bo := NewBrowserOpen(b)
|
||||
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
|
||||
t.Fatalf("expected direct URL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
|
||||
t.Fatal("expected exact user-provided URL to be allowed")
|
||||
}
|
||||
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
|
||||
t.Fatal("did not expect modified URL to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayPage_InvalidLoc(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
p := makeTestPage("https://example.com/x")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directURLContextKey struct{}
|
||||
|
||||
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
|
||||
|
||||
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
|
||||
allowed := make(map[string]struct{})
|
||||
for _, match := range directURLPattern.FindAllString(text, -1) {
|
||||
addAllowedDirectURLToMap(allowed, match)
|
||||
}
|
||||
return context.WithValue(ctx, directURLContextKey{}, allowed)
|
||||
}
|
||||
|
||||
func addAllowedDirectURL(ctx context.Context, raw string) {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
addAllowedDirectURLToMap(allowed, raw)
|
||||
}
|
||||
|
||||
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
|
||||
if allowed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw = cleanDirectURL(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
allowed[raw] = struct{}{}
|
||||
}
|
||||
|
||||
func allowedDirectURL(ctx context.Context, raw string) bool {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
cleaned := cleanDirectURL(raw)
|
||||
if cleaned == "" || cleaned != raw {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := allowed[cleaned]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cleanDirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, ".,;:!?)]}")
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
|
||||
|
||||
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
|
||||
t.Fatal("expected changed tool argument to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/privacy") {
|
||||
t.Fatal("expected URL wrapped in backticks to be allowed")
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,17 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
if !allowedDirectURL(ctx, urlStr) {
|
||||
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, link := range result.Links {
|
||||
addAllowedDirectURL(ctx, link)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, result := range result.Results {
|
||||
addAllowedDirectURL(ctx, result.URL)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -414,7 +414,10 @@ export class Settings {
|
||||
ThinkLevel: string;
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
AutoUpdateEnabled: boolean;
|
||||
ClaudeDesktopUsed: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
@@ -432,7 +435,10 @@ export class Settings {
|
||||
this.ThinkLevel = source["ThinkLevel"];
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.LastHomeView = source["LastHomeView"];
|
||||
this.OnboardingVersion = source["OnboardingVersion"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
this.ClaudeDesktopUsed = source["ClaudeDesktopUsed"];
|
||||
}
|
||||
}
|
||||
export class SettingsResponse {
|
||||
@@ -550,14 +556,12 @@ export class Error {
|
||||
}
|
||||
}
|
||||
export class ModelUpstreamResponse {
|
||||
digest?: string;
|
||||
pushTime: number;
|
||||
stale: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.digest = source["digest"];
|
||||
this.pushTime = source["pushTime"];
|
||||
this.stale = source["stale"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<html lang="en" style="overflow: hidden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Ollama</title>
|
||||
</head>
|
||||
<body class="dark:bg-neutral-900 select-text">
|
||||
<body class="bg-white dark:bg-neutral-900 select-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -56,6 +57,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
@@ -4593,6 +4595,16 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@@ -11152,6 +11164,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^19.1.0",
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer/node_modules/react-is": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -65,6 +66,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="92" height="96" viewBox="0 0 92 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#24292F">
|
||||
<path fill-rule="evenodd" d="M65.45 16.8c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4 4 0 0 1-.01 3.6l-5.73 11.34v6.6c0 10.94-8.82 19.8-19.71 19.8H26.02C15.13 96 6.31 87.14 6.31 76.2v-6.6L.45 58.3a4 4 0 0 1-.01-3.67l5.87-11.43v-6.6c0-10.94 8.82-19.8 19.71-19.8h39.43Zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88V45L7.44 54.46a4 4 0 0 0 .01 3.68L12.32 67.5v5.63c0 9.32 7.55 16.87 16.87 16.87h33.74c9.32 0 16.87-7.55 16.87-16.87V67.5l4.77-9.39a4 4 0 0 0 .01-3.61L79.8 45v-5.62c0-9.32-7.55-16.88-16.87-16.88Z"/>
|
||||
<circle cx="45.73" cy="11.5" r="11"/>
|
||||
<rect x="27" y="41" width="13" height="30" rx="6.5"/>
|
||||
<rect x="51" y="41" width="13" height="30" rx="6.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g transform="scale(32)"><clipPath id="codex-color-clip"><path d="M0 0h16v16H0z"/></clipPath><g clip-path="url(#codex-color-clip)"><path d="M13.003 0H2.997A3.012 3.012 0 000 2.997v10.006A3.012 3.012 0 002.997 16h10.006A3.012 3.012 0 0016 13.003V2.997A3.012 3.012 0 0013.003 0z" fill="#fff" fill-rule="nonzero"/><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.104.104 0 00.043 0 4.556 4.556 0 013.046.275l.047.022.116.057a4.585 4.585 0 012.188 2.399c.209.51.313 1.041.315 1.595.015.412-.03.824-.134 1.223a.124.124 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.12.12 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.109.109 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.438 4.438 0 01-1.945-.466 4.553 4.553 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.797 5.797 0 01-.37-.961 4.575 4.575 0 01-.014-2.298.133.133 0 00.006-.056.083.083 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.898 3.898 0 01-.251-1.192 5.193 5.193 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33a6.29 6.29 0 01.646-.227.1.1 0 00.065-.066 4.512 4.512 0 01.829-1.615 4.54 4.54 0 011.837-1.388zm3.482 10.565a.64.64 0 00-.601.636.64.64 0 00.601.636h3.636l.036.001a.64.64 0 00.637-.637.64.64 0 00-.637-.637l-.036.001h-3.636zM8.462 9.23a.64.64 0 00-.543-.304.64.64 0 00-.563.935l1.272 2.224-1.266 2.136a.638.638 0 001.095.649l1.454-2.455a.637.637 0 00.005-.64L8.462 9.23z" fill="url(#codex-color-gradient)" fill-rule="nonzero" transform="scale(.66667)"/></g></g><defs><linearGradient id="codex-color-gradient" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 18 -18 0 12 3)"><stop offset="0" stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path fill="#fff" d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z" fill="white"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#4D6BFE" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,181 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000"><circle cx="500.0" cy="500.0" r="500.0" fill="white"/><g transform="translate(100.0 100.0) scale(0.8333333333333334)"><g transform="translate(0.000000,960.000000) scale(0.100000,-0.100000)"
|
||||
fill="black" stroke="none">
|
||||
<path d="M4485 9589 c-248 -27 -432 -60 -730 -130 -458 -108 -798 -230 -1207
|
||||
-435 -533 -267 -1072 -675 -1358 -1030 -205 -255 -442 -748 -535 -1114 -108
|
||||
-426 -97 -870 29 -1160 73 -169 236 -369 381 -467 139 -94 425 -206 425 -167
|
||||
0 3 -26 32 -58 63 -74 71 -147 182 -184 278 -16 41 -27 77 -25 79 2 3 31 -28
|
||||
63 -68 91 -114 153 -177 231 -235 40 -29 77 -62 83 -73 7 -13 4 -85 -10 -242
|
||||
-10 -123 -24 -281 -30 -353 -6 -71 -29 -296 -51 -500 -135 -1262 -202 -1568
|
||||
-378 -1733 -69 -64 -105 -77 -216 -77 -132 0 -188 20 -271 97 -110 103 -165
|
||||
248 -167 438 -1 123 12 191 60 318 20 51 33 95 29 99 -10 10 -92 -74 -136
|
||||
-137 -153 -223 -204 -504 -146 -790 19 -91 97 -252 161 -333 112 -141 316
|
||||
-237 504 -237 180 0 419 118 591 290 81 81 107 115 196 255 50 78 54 56 13
|
||||
-71 -107 -337 -343 -577 -613 -625 -213 -39 -544 99 -707 295 -93 111 -133
|
||||
199 -173 381 -34 153 -34 154 -46 135 -15 -23 -12 -194 5 -305 42 -280 149
|
||||
-488 327 -636 133 -111 287 -195 465 -254 62 -20 115 -40 117 -44 3 -4 12 -43
|
||||
21 -87 44 -222 199 -385 416 -438 96 -24 265 -21 359 5 138 38 281 148 388
|
||||
297 39 55 48 63 50 45 4 -27 -38 -185 -78 -294 -80 -217 -176 -374 -312 -515
|
||||
-54 -55 -98 -104 -98 -107 0 -4 245 -7 545 -7 l544 0 126 66 c69 36 130 64
|
||||
135 62 6 -2 -10 -28 -34 -59 -46 -59 -48 -69 -10 -69 17 0 32 15 58 59 36 59
|
||||
51 71 87 71 23 0 25 -27 4 -77 -8 -19 -15 -39 -15 -44 0 -12 447 -11 470 1 10
|
||||
5 85 87 168 182 257 297 400 415 317 263 -16 -30 -26 -57 -23 -60 11 -12 210
|
||||
100 383 215 115 76 226 143 375 225 58 32 178 100 266 151 150 87 244 132 244
|
||||
117 0 -4 -78 -86 -174 -182 -207 -208 -246 -254 -334 -386 -48 -71 -122 -157
|
||||
-259 -298 -117 -120 -193 -206 -193 -217 0 -19 8 -20 115 -20 101 0 116 2 122
|
||||
18 19 54 112 249 156 327 124 221 283 436 369 502 87 67 225 152 337 209 103
|
||||
51 297 117 384 130 38 6 29 -2 -92 -75 -74 -45 -170 -107 -214 -139 -106 -76
|
||||
-247 -225 -332 -351 -67 -99 -70 -102 -79 -77 -6 14 -13 26 -18 26 -11 0 -57
|
||||
-75 -94 -155 -63 -136 -124 -376 -103 -401 15 -18 152 -19 166 -2 6 7 18 36
|
||||
27 63 23 72 66 131 217 301 302 339 430 464 590 577 148 104 233 128 520 148
|
||||
205 14 255 9 447 -38 118 -29 183 -65 298 -163 229 -195 538 -577 579 -715 24
|
||||
-83 71 -117 109 -79 9 8 16 27 16 42 0 62 -40 212 -73 277 -158 313 -551 635
|
||||
-922 755 -127 41 -142 55 -52 46 117 -11 231 -35 321 -65 158 -54 259 -123
|
||||
405 -277 164 -174 283 -379 322 -556 11 -51 25 -113 32 -137 36 -128 148 -81
|
||||
117 49 -14 60 -7 106 27 177 36 74 62 94 224 179 254 133 425 260 561 417 272
|
||||
315 403 732 358 1138 -24 218 -84 401 -179 545 -65 98 -155 203 -166 192 -3
|
||||
-3 7 -37 23 -75 136 -309 136 -725 2 -1063 -130 -330 -441 -760 -633 -879 -60
|
||||
-37 -60 -20 2 54 478 577 578 1337 315 2390 -25 99 -86 326 -136 505 -169 611
|
||||
-386 1539 -494 2109 -161 857 -200 998 -442 1606 -161 405 -321 692 -529 950
|
||||
-93 114 -322 343 -433 431 -203 160 -497 332 -737 428 -293 118 -702 220 -978
|
||||
246 -129 12 -406 11 -520 -1z m-267 -214 c301 -47 596 -191 852 -419 85 -76
|
||||
266 -270 345 -371 294 -376 520 -873 640 -1409 45 -199 45 -222 3 -244 -18 -9
|
||||
-45 -30 -61 -45 -25 -23 -28 -33 -23 -60 9 -44 21 -54 121 -98 91 -40 225
|
||||
-124 225 -140 0 -9 -33 5 -211 89 -126 60 -159 94 -167 173 -5 50 14 89 43 89
|
||||
26 0 65 49 65 81 0 52 -22 110 -48 127 -23 15 -37 14 -186 -7 -172 -25 -210
|
||||
-36 -240 -69 -23 -27 -31 -99 -14 -131 16 -29 30 -37 85 -51 26 -7 44 -17 48
|
||||
-29 10 -32 -15 -153 -41 -197 -27 -49 -131 -161 -139 -152 -9 9 36 77 85 126
|
||||
23 24 48 60 56 79 13 31 13 38 -3 71 -10 20 -29 42 -43 48 -14 7 -37 18 -52
|
||||
24 -33 15 -42 39 -63 155 -24 133 -90 393 -135 532 -119 367 -287 686 -483
|
||||
919 -175 208 -434 427 -659 557 -229 131 -498 197 -804 197 -136 0 -185 -4
|
||||
-313 -26 -87 -14 -31 14 137 70 186 61 289 89 437 117 120 23 376 20 543 -6z
|
||||
m2913 -962 c50 -53 113 -138 198 -268 76 -116 60 -104 -41 32 -34 46 -63 81
|
||||
-66 79 -2 -2 2 -30 9 -63 9 -44 9 -86 1 -171 -6 -63 -13 -116 -16 -119 -3 -4
|
||||
-15 2 -26 12 -32 29 -34 12 -5 -57 50 -118 66 -160 62 -164 -2 -2 -29 34 -61
|
||||
81 -31 47 -61 83 -66 80 -6 -4 -7 -24 -4 -47 7 -39 6 -40 -14 -27 -12 7 -25
|
||||
10 -29 5 -17 -17 -4 -106 31 -210 20 -60 35 -111 33 -112 -2 -2 -30 43 -62 99
|
||||
-60 102 -173 233 -182 210 -2 -7 26 -82 62 -168 37 -85 65 -160 63 -166 -2 -6
|
||||
-43 66 -91 160 -63 121 -93 171 -106 171 -9 0 -37 -21 -61 -46 -55 -56 -56
|
||||
-56 -248 20 -78 31 -156 59 -172 63 l-30 6 24 -54 c13 -30 51 -114 85 -188 34
|
||||
-73 60 -135 58 -137 -2 -3 -21 24 -42 58 -56 91 -85 128 -102 128 -13 0 -14
|
||||
-8 -9 -42 l7 -42 -83 80 c-109 106 -163 133 -260 126 -35 -3 -38 0 -53 34 -9
|
||||
21 -13 44 -11 51 7 17 58 16 147 -2 101 -21 104 -17 92 106 -6 52 -7 98 -3
|
||||
102 4 5 25 -20 47 -55 22 -35 47 -68 54 -75 18 -14 278 -83 316 -83 23 0 42
|
||||
13 86 59 61 64 64 72 42 111 -19 33 -19 54 0 46 11 -4 23 6 37 30 l21 35 66
|
||||
-3 66 -3 -2 29 c-1 16 -25 63 -52 105 -28 41 -49 77 -47 78 2 2 47 -42 101
|
||||
-97 75 -76 105 -100 125 -100 14 0 35 -7 47 -15 20 -14 22 -14 27 2 3 10 6 72
|
||||
7 138 2 102 -1 128 -19 175 -12 30 -22 56 -22 58 0 9 40 -22 71 -55z m-2642
|
||||
-139 c29 -35 60 -64 67 -64 8 0 52 27 97 61 l82 60 47 -51 c59 -63 161 -218
|
||||
218 -327 23 -46 48 -83 55 -83 8 0 26 5 41 11 49 18 73 4 104 -64 33 -70 48
|
||||
-127 33 -127 -6 0 -32 9 -58 21 -28 12 -56 18 -71 15 -21 -6 -27 1 -55 56 -67
|
||||
132 -208 340 -244 361 -10 5 -19 -1 -29 -22 -29 -55 -35 -106 -20 -183 8 -40
|
||||
14 -74 14 -75 0 -1 -12 2 -26 8 l-27 10 7 -62 c6 -61 6 -62 -14 -44 -15 14
|
||||
-31 17 -72 13 -58 -6 -88 -32 -88 -76 l0 -26 -29 34 c-29 35 -30 35 -122 38
|
||||
-52 2 -99 8 -106 14 -15 12 -83 132 -83 146 0 6 23 39 50 73 56 70 68 99 52
|
||||
134 -12 27 -15 25 81 46 65 14 68 21 42 105 -26 85 -18 85 54 -2z m-397 -294
|
||||
c84 -126 196 -352 237 -475 33 -99 28 -115 -23 -66 -45 44 -55 29 -49 -77 5
|
||||
-95 -4 -97 -33 -7 -22 67 -52 125 -64 125 -6 0 -10 -39 -11 -92 0 -51 -4 -101
|
||||
-8 -111 -9 -23 -10 -22 -85 101 -32 50 -62 92 -68 92 -7 0 -9 -22 -4 -70 6
|
||||
-74 -3 -90 -24 -40 -21 50 -33 54 -87 30 -26 -11 -57 -30 -69 -41 -20 -19 -21
|
||||
-19 -75 10 -30 17 -91 41 -137 54 -46 13 -89 30 -97 38 -16 16 -45 142 -36
|
||||
157 3 5 58 33 121 61 l114 51 21 -26 21 -26 49 39 c51 40 69 66 80 116 5 23
|
||||
10 28 32 25 19 -2 28 -11 37 -38 37 -115 42 114 6 250 -45 171 -47 164 22 90
|
||||
33 -36 92 -112 130 -170z m-1789 73 c-3 -10 -32 -77 -63 -148 -48 -109 -137
|
||||
-340 -242 -628 -11 -32 -22 -56 -24 -54 -2 2 -9 32 -14 68 -24 141 -20 131
|
||||
-47 124 -13 -3 -50 -18 -81 -33 -43 -20 -62 -37 -79 -67 -32 -59 -40 -65 -88
|
||||
-65 -55 0 -56 6 -16 106 29 75 176 339 194 351 12 7 14 14 -47 -125 -25 -56
|
||||
-46 -113 -46 -127 0 -25 0 -25 35 -11 109 46 179 120 301 316 135 217 241 360
|
||||
217 293z m-869 -35 c-4 -7 -33 -49 -64 -93 -140 -200 -268 -431 -368 -665
|
||||
-114 -268 -153 -314 -74 -86 41 115 44 129 29 137 -28 16 -39 80 -22 128 27
|
||||
77 98 202 139 246 58 61 355 345 362 345 3 0 2 -6 -2 -12z m1415 -533 l1 -130
|
||||
-23 39 c-26 47 -41 51 -45 14 -4 -36 -18 -35 -37 1 -8 17 -19 32 -25 36 -5 3
|
||||
-60 -10 -122 -30 -76 -25 -130 -36 -165 -36 -29 1 -82 -5 -118 -14 -99 -23
|
||||
-109 -21 -149 29 -20 23 -36 50 -36 58 0 12 55 182 75 231 11 27 38 21 147
|
||||
-34 76 -38 108 -49 130 -45 21 4 28 2 28 -9 0 -10 11 -15 34 -15 45 0 153 34
|
||||
177 56 10 9 35 69 55 133 56 180 58 180 65 1 4 -85 7 -213 8 -285z m-1394 272
|
||||
c-31 -55 -32 -83 -2 -91 47 -12 65 -6 97 34 18 23 34 39 36 38 2 -2 -20 -48
|
||||
-48 -102 l-50 -99 33 7 c84 17 149 19 149 5 0 -8 -37 -104 -82 -213 -74 -177
|
||||
-83 -195 -86 -162 -4 50 -26 56 -66 17 -17 -17 -35 -31 -39 -31 -4 0 -7 18 -7
|
||||
40 0 28 -6 43 -18 51 -15 10 -18 21 -14 65 8 93 -18 69 -89 -80 -35 -74 -65
|
||||
-133 -67 -131 -3 2 5 35 17 74 11 38 21 74 21 80 0 6 -35 11 -87 13 l-88 3 3
|
||||
30 c4 42 162 364 187 381 11 8 44 14 76 14 55 0 59 2 93 42 20 23 36 45 36 50
|
||||
0 4 5 8 10 8 6 0 -1 -20 -15 -43z m1710 6 c120 -17 143 -19 164 -12 10 3 21
|
||||
-17 37 -67 24 -77 75 -306 69 -312 -2 -2 -21 14 -43 37 l-39 41 -145 0 c-128
|
||||
0 -148 2 -170 19 -16 13 -29 17 -39 10 -8 -5 -17 -9 -20 -9 -10 0 -66 178 -74
|
||||
232 -4 25 -4 56 0 68 7 21 11 22 79 16 39 -4 121 -14 181 -23z m-79 -988 c74
|
||||
-190 129 -303 247 -514 76 -136 79 -145 62 -157 -24 -17 -76 -18 -98 -1 -21
|
||||
16 -126 237 -165 347 -30 87 -143 516 -141 541 1 19 17 -19 95 -216z m3644 61
|
||||
c64 -163 185 -534 301 -926 66 -223 147 -490 179 -595 175 -566 250 -858 321
|
||||
-1240 22 -121 43 -231 46 -245 4 -18 3 -22 -6 -15 -6 6 -22 71 -36 145 -58
|
||||
313 -100 487 -200 820 -40 135 -94 317 -120 405 -161 550 -413 1387 -465 1540
|
||||
-55 165 -67 205 -56 194 2 -2 18 -39 36 -83z m-4395 -812 c61 -60 129 -118
|
||||
149 -128 46 -22 69 -19 238 25 70 18 130 30 133 27 3 -3 -19 -42 -49 -87 -53
|
||||
-78 -75 -125 -63 -137 14 -14 111 32 205 96 57 38 136 85 176 104 84 40 299
|
||||
116 327 116 12 0 37 -24 66 -65 25 -36 54 -67 62 -69 9 -2 178 -1 376 2 l360
|
||||
7 10 70 c10 68 10 69 22 40 7 -16 17 -49 23 -73 14 -53 18 -55 187 -82 178
|
||||
-28 191 -33 200 -78 12 -57 8 -612 -6 -742 -17 -170 -53 -395 -141 -880 -206
|
||||
-1142 -248 -1540 -194 -1863 8 -49 12 -97 9 -107 -8 -24 -195 -200 -213 -200
|
||||
-7 0 -21 14 -30 31 -140 256 -353 528 -467 594 -66 39 -95 39 -361 1 -137 -19
|
||||
-303 -40 -368 -47 -128 -12 -307 -7 -368 11 -54 16 -140 78 -185 132 -41 51
|
||||
-348 610 -438 801 -105 220 -178 478 -191 667 -7 97 11 328 25 343 5 5 23 -17
|
||||
41 -49 18 -32 72 -97 125 -148 54 -53 97 -104 101 -120 12 -50 -1 -119 -34
|
||||
-170 -22 -33 -32 -62 -32 -88 0 -45 31 -126 70 -182 23 -35 28 -50 23 -82 -3
|
||||
-24 4 -70 17 -119 18 -68 28 -87 68 -128 59 -60 118 -101 132 -92 6 4 10 18 8
|
||||
32 -3 22 3 28 48 44 28 11 59 28 70 40 l18 20 -107 -7 c-118 -8 -146 0 -166
|
||||
43 -17 37 -14 52 18 84 33 33 78 41 66 12 -14 -31 -16 -77 -5 -93 9 -13 14
|
||||
-11 36 14 21 24 25 37 21 68 -5 37 -4 38 36 49 56 15 192 6 242 -15 l40 -17
|
||||
-27 -20 c-66 -49 -1 -50 120 -2 80 31 92 40 92 64 0 33 -30 52 -72 45 -33 -5
|
||||
-51 1 -140 49 -226 121 -267 139 -327 139 -31 1 -68 -3 -83 -8 -24 -7 -32 -3
|
||||
-62 30 -62 67 -63 76 -30 145 53 111 38 151 -110 308 -88 93 -131 162 -142
|
||||
230 -11 67 0 84 99 164 142 113 222 232 261 384 46 178 -18 329 -188 441 -74
|
||||
48 -75 49 -51 60 35 16 31 28 -20 64 -31 22 -41 34 -32 40 21 14 168 59 252
|
||||
77 67 15 80 21 83 39 2 12 -33 92 -82 187 -100 193 -117 263 -35 144 28 -41
|
||||
102 -124 164 -185z m-430 -364 c-13 -21 19 -51 100 -97 78 -43 142 -93 119
|
||||
-93 -5 0 -34 7 -64 15 -69 19 -148 19 -180 0 -24 -14 -24 -14 21 -15 63 0 238
|
||||
-37 267 -56 24 -16 42 -52 42 -85 0 -17 -8 -14 -57 24 -92 71 -138 91 -213 90
|
||||
-36 0 -85 -8 -109 -17 -55 -20 -64 -14 -103 71 -37 82 -36 104 4 127 60 36
|
||||
190 63 173 36z m4328 -154 c76 -37 148 -103 127 -116 -27 -17 -107 -10 -174
|
||||
15 -91 34 -212 35 -310 1 -55 -19 -76 -22 -100 -14 -17 5 -37 12 -45 14 -17 6
|
||||
18 45 66 75 76 47 131 59 258 57 109 -3 125 -6 178 -32z m-4293 -282 c3 -28
|
||||
11 -58 17 -66 7 -8 12 -30 12 -49 -1 -40 10 -72 46 -128 33 -53 32 -67 -5 -80
|
||||
-78 -27 -118 37 -133 210 -10 105 -9 120 7 144 27 42 50 29 56 -31z m-53 -438
|
||||
c-4 -9 -11 -16 -17 -16 -11 0 -14 33 -3 44 11 10 26 -11 20 -28z m449 -922
|
||||
c60 -20 62 -23 44 -34 -23 -15 -104 -12 -126 5 -19 15 -19 15 0 30 24 18 22
|
||||
19 82 -1z m5843 -211 c82 -179 180 -472 218 -653 34 -160 38 -387 10 -517 -37
|
||||
-172 -107 -345 -191 -471 -81 -122 -215 -266 -232 -249 -2 2 10 37 27 78 188
|
||||
447 261 1077 185 1584 -15 98 -31 196 -35 217 -10 44 0 50 18 11z m-2689 -313
|
||||
c25 -333 24 -319 22 -342 -1 -10 -66 -56 -164 -115 -175 -106 -203 -121 -196
|
||||
-101 3 7 26 81 53 163 39 124 214 633 241 699 15 37 22 -12 44 -304z m110
|
||||
-832 c37 -172 37 -173 -56 -257 -80 -73 -143 -103 -230 -109 -105 -7 -132 9
|
||||
-183 108 -53 101 -53 132 -3 177 21 19 126 88 233 153 182 111 194 117 201 97
|
||||
3 -12 20 -88 38 -169z m-1046 -650 c67 -151 141 -236 331 -383 138 -106 239
|
||||
-173 309 -204 42 -18 47 -23 36 -36 -7 -9 -110 -81 -229 -160 -178 -118 -251
|
||||
-161 -416 -238 -110 -51 -250 -117 -312 -147 -62 -29 -118 -50 -126 -47 -7 3
|
||||
-24 34 -36 69 -28 77 -74 158 -252 445 -75 122 -140 231 -144 242 -6 19 -2 21
|
||||
42 21 63 0 171 24 230 50 35 15 99 72 238 209 182 180 271 261 285 261 4 0 24
|
||||
-37 44 -82z m-2387 -88 c-10 -81 -64 -284 -102 -378 -55 -136 -119 -238 -204
|
||||
-323 -108 -107 -162 -132 -306 -137 -109 -4 -111 -3 -175 30 -42 22 -76 48
|
||||
-98 78 l-35 45 114 7 c338 22 478 105 645 383 29 50 70 131 89 180 49 124 67
|
||||
165 73 165 3 0 2 -22 -1 -50z m1647 -1402 c-54 -78 -300 -358 -315 -358 -16 0
|
||||
-10 15 29 76 113 173 323 408 330 370 2 -10 -18 -49 -44 -88z"/>
|
||||
<path d="M3229 5845 c-108 -15 -150 -30 -198 -71 -49 -41 -111 -123 -111 -146
|
||||
0 -18 5 -20 40 -15 22 3 40 3 40 1 0 -2 -9 -26 -20 -53 -12 -32 -16 -52 -9
|
||||
-56 9 -6 7 -23 -8 -62 -3 -8 4 -13 20 -13 16 0 26 -7 30 -20 8 -30 33 -24 48
|
||||
12 15 37 122 148 142 148 12 0 11 -10 0 -56 -17 -66 -10 -118 17 -137 15 -11
|
||||
19 -21 14 -44 -5 -25 2 -39 44 -90 28 -33 66 -67 85 -76 39 -19 112 -22 152
|
||||
-7 39 15 108 74 140 121 27 38 28 41 13 72 -15 32 -15 34 8 54 13 12 24 33 24
|
||||
47 l0 25 38 -17 c58 -26 111 -62 122 -81 6 -13 3 -29 -11 -57 -98 -186 -404
|
||||
-264 -685 -174 -101 32 -143 37 -162 18 -21 -21 -13 -28 31 -28 49 0 82 -19
|
||||
91 -53 5 -20 11 -23 31 -19 14 2 31 0 38 -6 17 -14 145 -34 168 -27 11 4 26 1
|
||||
34 -5 9 -7 23 -9 37 -4 13 5 41 9 63 11 22 1 51 3 65 4 14 1 37 -1 52 -5 21
|
||||
-6 27 -4 33 13 5 18 14 21 55 21 43 0 49 3 52 23 3 19 10 22 51 25 49 3 53 7
|
||||
37 32 -11 17 4 30 38 30 15 0 22 6 22 19 0 14 16 27 55 45 40 18 56 31 61 51
|
||||
3 14 16 32 27 40 18 13 20 18 10 34 -11 17 -8 21 25 35 100 44 116 63 55 68
|
||||
-33 3 -38 6 -36 26 3 22 0 22 -45 16 -44 -6 -49 -4 -83 29 -58 56 -354 199
|
||||
-469 227 -116 28 -147 43 -70 36 30 -3 108 -23 173 -45 64 -22 117 -36 117
|
||||
-31 0 13 -23 24 -160 75 -142 53 -197 60 -331 40z"/>
|
||||
<path d="M3027 5303 c-3 -5 -2 -15 2 -22 7 -10 10 -10 16 -1 4 6 3 16 -3 22
|
||||
-5 5 -12 6 -15 1z"/>
|
||||
<path d="M2180 4462 c0 -11 136 -122 149 -122 19 0 12 46 -11 75 -12 15 -36
|
||||
34 -54 41 -37 15 -84 19 -84 6z"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="omp-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#ed4abf"/>
|
||||
<stop offset=".5" stop-color="#9b4dff"/>
|
||||
<stop offset="1" stop-color="#5ad8e6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0f0a14"/>
|
||||
<path fill="url(#omp-gradient)" d="M14 16h36v8H40v32h-8V24h-6v22h-8V24h-4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,242 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500">
|
||||
<style>
|
||||
.s0 { fill: #f6f4f4 }
|
||||
.s1 { fill: #0b0303 }
|
||||
.s2 { fill: #ef0011 }
|
||||
.s3 { fill: #f3e2e2 }
|
||||
.s4 { fill: #f00212 }
|
||||
.s5 { fill: #ba000d }
|
||||
.s6 { fill: #faf1f1 }
|
||||
.s7 { fill: #0b0100 }
|
||||
.s8 { fill: #fbedee }
|
||||
.s9 { fill: #faeaea }
|
||||
.s10 { fill: #ab797d }
|
||||
.s11 { fill: #f8eaea }
|
||||
.s12 { fill: #902021 }
|
||||
.s13 { fill: #f9eeee }
|
||||
.s14 { fill: #f6ecec }
|
||||
.s15 { fill: #080201 }
|
||||
.s16 { fill: #150100 }
|
||||
.s17 { fill: #f2e7e7 }
|
||||
.s18 { fill: #fbe7e8 }
|
||||
.s19 { fill: #060101 }
|
||||
.s20 { fill: #f5e7e7 }
|
||||
.s21 { fill: #fa999e }
|
||||
.s22 { fill: #c46064 }
|
||||
.s23 { fill: #180300 }
|
||||
.s24 { fill: #f6dcdd }
|
||||
.s25 { fill: #f2e6e6 }
|
||||
.s26 { fill: #110200 }
|
||||
.s27 { fill: #eb0011 }
|
||||
.s28 { fill: #e20010 }
|
||||
.s29 { fill: #ea0011 }
|
||||
.s30 { fill: #760007 }
|
||||
.s31 { fill: #f00514 }
|
||||
.s32 { fill: #fcebeb }
|
||||
.s33 { fill: #ecd6d6 }
|
||||
.s34 { fill: #f5e3e3 }
|
||||
.s35 { fill: #f5e4e4 }
|
||||
.s36 { fill: #faf6f6 }
|
||||
.s37 { fill: #e50010 }
|
||||
.s38 { fill: #d5000f }
|
||||
.s39 { fill: #f2e2e3 }
|
||||
.s40 { fill: #ef1018 }
|
||||
.s41 { fill: #f4e8e9 }
|
||||
.s42 { fill: #ef0513 }
|
||||
.s43 { fill: #f5e5e5 }
|
||||
.s44 { fill: #f00413 }
|
||||
.s45 { fill: #f4e9ea }
|
||||
.s46 { fill: #ed0011 }
|
||||
.s47 { fill: #e80011 }
|
||||
.s48 { fill: #e60613 }
|
||||
.s49 { fill: #f0d6d6 }
|
||||
.s50 { fill: #fca9ac }
|
||||
.s51 { fill: #9c000c }
|
||||
.s52 { fill: #73393b }
|
||||
</style>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s0" d="m166.5 52.5q3.5 0 7 0 2.75 2.99 1.5 7-21.27 45.61-20.5 96 39.99 2.76 72 26.5 7.87 6.86 13.5 15.5 42.88-56.39 103.5-92.5 47.35-25.46 101-25 14.52 0.38 23.5 11.5 3.19 7.74 2 16-1.81 7.18-4.5 14-1 0-1 1-5.04 6.05-9 13-1 0-1 1 0 0.5 0 1-12.42 12.15-28.5 19-6.02 36.27-41.5 45-0.83 2.75 0 5 19.02-12.85 41.5-9 10.85-8.09 23.5-13 15.01-6.37 31-2.5 14.09 7.43 14 23.5-2.83 23.25-15.5 43-6.42 9.92-14 19-10.04 8.8-19.5 18-72.02 48.88-156.5 27-19.63 9.6-41.5 10.5-4.59 1.27-9 3 2 1 4 2 20.09-1.11 35 12 25.46 6.95 37.5 30.5 1.26 5.69-1 11-3.38 3.79-7.5 6.5 5.74 10.07 1.5 20.5-7.55 7.47-17.5 3.5-11.01-5.34-22.5-9.5-18.26 10-38.5 13-15.5 0-31 0-26.62-4.54-51-17-4.17 1.33-8 3.5-7.23 5.87-15 11-8.62 2.58-13.5-4.5-1.82 2.32-4.5 3.5-6.06 2.24-12 3.5-7.5 0-15 0-27.42-2.56-50-18.5-18-17.25-23-41.5 0-11.5 0-23 4.12-22.7 25-33 6.95-16.67 22-26.5-20.39-20.8-14.5-49.5 7.01-26.98 28.5-44.5 7.56-5.27 15-10.5-13.09-30.88-7.5-64 3.16-15.57 14.5-26.5 6.85-2.48 8 4.5-6.59 39.53 11 75.5 7.99-0.49 16-2 2.42-34.57 14.5-67.5 8.51-22.23 27.5-36z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s1" d="m113.5 401.5q0.48-5.1-1-10-0.91 0.19-1 1-2.46 1.74-5 3.5 5.65 9.54-5 13-32.21 5.55-61-10-32.89-23.11-29.5-63.5 2.96-22.67 23.5-32 7.99-19.75 27-29.5-27.65-23.7-15.5-58.5 7.33-16.82 20.5-29.5 10.79-8.14 22-15.5-16.49-37.08-5.5-76 3.19-6.13 7.5-11.5 1.48-0.89 2 1-5.69 41.09 12.5 78.5 1 1 2 2 9.97-3.24 20.5-4 2 0 4 0 0-7.5 0-15 0.99-42.22 24.5-77 6.12-7.12 14-12-4.65 13.43-10 27-11.93 37.6-9.5 77 49.38 0.7 83.5 36 2.75 4.5 5.5 9 38.99-52.24 93-88.5 45.84-29.03 100-32.5 15.69-1.56 29 6.5 5.68 7.29 3.5 16.5-10.38 33.62-43.5 45-4.39 37.33-41 45-0.79 8.63-6 15.5 1.91 1.83 4.5 2.5 22.27-17.25 50.5-14.5 12.93-9.41 28-15 36.22-8.28 31.5 28.5-15.19 51.69-62.5 77.5-65.92 35.87-138 15.5-19.67 10.42-42 10.5-8.39 2.88-17 5 3.58 6.08 10 9 20.92-1.14 36 13 22.67 5.23 34.5 25.5 3.33 7.13-3.5 11.5-3.88 1.8-8 3 7.36 8.45 6.5 19.5-4.43 5.66-11.5 3.5-12.84-5.67-26-10.5-39.4 21.02-83 10.5-18.85-5.78-36.5-14.5-13.65 4.14-23.5 14.5-9.51 3.74-11-6.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s2" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s3" d="m454.5 97.5q-18.37-2.97-37-1.5-16.14 2.08-32 5.5 32.38-14.09 67-7.5 1.98 1.22 2 3.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s4" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s5" d="m86.5 112.5q-1-6.5-2-13 0.7-5.34 3.5-10-1.8 11.32-1.5 23z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s6" d="m433.5 97.5q2.22-0.39 4 1-10 13.75-27 14-0.24-2.06 0.5-4 10.3-7.78 22.5-11z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s7" d="m407.5 101.5q2.55-0.24 5 0.5-52.87 18.31-84.5 64.5-6.94 7.95-17 11-9.38-2.38-5-11 40.38-48.62 101.5-65z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s8" d="m402.5 112.5q3 0 6 0-2.56 8.8-12 7-0.22-1.58 0.5-3 2.72-2.22 5.5-4z"/>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s9" d="m390.5 149.5q7.77 0.52 15 2-11.29 18.28-31 27 9.94-8.7 19.5-20 1-2 0-4-3.19-1.68-5.5-4.5 0.89-0.7 2-0.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s10" d="m131.5 145.5q0 7.5 0 15-2 0-4 0 1.06-1.36 3-1-0.48-7.29 1-14z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s11" d="m219.5 204.5q-1 4.5-2 9 0.24-2.55-0.5-5-5.39-13.4-17.5-21.5-21.38-12.04-46-13.5 0-2 0-4 36.7-0.86 61.5 26 3.06 4.11 4.5 9z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s12" d="m329.5 191.5q6.2-1.48 13-1-3.5 1-7 2-2.9-0.97-6-1z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s13" d="m329.5 191.5q3.1 0.03 6 1 9.55 1.31 19 3-10.84 26.1-38 33.5 16.22-10.47 27-26.5-8.19-4.08-14-11z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s14" d="m479.5 199.5q-7.22-2.86-15-1.5-12.3 2.32-24 6.5 15.6-13.11 36-11.5 3.63 2.26 3 6.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s15" d="m193.5 216.5q-12.01 1.52-22 8-2.83 1.29-5.5 3-4.79-4.57-6.5-11-5.04 2.2-9.5-1-3.47-6.4 3.5-3 4.4 0.05 8-2.5 9.22-9.73 21-16 6.3-3.24 12 1-2.9 1.22-6 1.5 2.61 5.74 4.5 12 0.75 3.97 0.5 8z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s16" d="m458.5 200.5q3.04-0.24 6 0.5-18.02 7.05-33 19-1 1-2 0 11.53-14.3 29-19.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s17" d="m178.5 202.5q6.85-0.63 4.5 6-7.6 5.09-6-4 1.08-0.82 1.5-2z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s18" d="m469.5 201.5q-2.26 13.65-14.5 22-0.47-2.11 1-4 7.08-8.82 13.5-18z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s19" d="m74.5 208.5q8.22-0.2 16 2.5 11.8 4.26 23.5 8.5 5.65-0.63 8-6 2.41 11.83-9.5 13 0.55 3.61 2 7-0.5 1-1 2-4.67-0.94-9.5-1-9.96 0.44-19.5 2.5-5.05-3.55-6.5-9.5-0.75-7.48-0.5-15-6.47 0.15-3-4z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s20" d="m429.5 212.5q-2.5 1-5 2-4 0-8 0-14.2-1.07-27 5 15.27-12.44 35-9.5 2.72 1.14 5 2.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s21" d="m219.5 204.5q0.48 5.1-1 10-0.91-0.19-1-1 1-4.5 2-9z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s22" d="m416.5 215.5q0-0.5 0-1 4 0 8 0-2.29 1.21-5 2-1.06-1.36-3-1z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s23" d="m416.5 215.5q1.94-0.36 3 1-18.21 6.91-36 15-0.5-0.25-1-0.5 14.66-12.81 34-15.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s24" d="m193.5 216.5q4.39 1.3 9 3-0.79 1.04-2 1.5-14.77-0.13-29 3.5 9.99-6.48 22-8z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s25" d="m98.5 219.5q6.09-0.98 6 5-3.04 0.24-6-0.5-1.84-2.24 0-4.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s26" d="m176.5 229.5q8.85-1.14 16 4-4.98 1.75-10 0-13.56 14.3-33 19.5-28.06 8.2-55 1 3.32-6.4 10-5.5-0.71 1.47-2 2.5 36.58 4.24 69-14 4.68-2.13 1-5 2.35-0.91 4-2.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s27" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s28" d="m243.5 259.5q5.88 3.62 10.5 9 12.96 18.46 32.5 29.5-31.51-7.75-43-38.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s29" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s27" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s30" d="m129.5 288.5q2 1 4 2-3.14 0.27-6-1-0.77-1.4 2-1z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s31" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s32" d="m45.5 317.5q4.03-0.25 8 0.5 2.46 4.16-2 6-6.04 2.01-9-3.5 1.26-1.85 3-3z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s33" d="m56.5 313.5q4.91 3.14 9.5 7 0.88 2.25-1.5 3-4.57-4.57-8-10z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s34" d="m198.5 319.5q-11.1 11.56-27 15.5-15.75 4.88-32 2.5 28.81-3.69 54-18.5 2.65-0.96 5 0.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s4" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s35" d="m92.5 356.5q-9.09-26.85-38-28.5 0.89-0.7 2-0.5 25.47-4.89 35.5 19 0.75 4.98 0.5 10z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s36" d="m72.5 335.5q3.62-0.38 5 3-4.22 1.83-5-3z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s37" d="m223.5 336.5q5.59-0.48 11 1-4.04 4.16-8.5 8-5.99-3.8-2.5-9z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s38" d="m90.5 334.5q0.59-1.54 2-0.5 3.94 5.45 9 10 7 6 14 12-6.91-1.7-13-6-6.21-7.72-12-15.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s39" d="m261.5 346.5q-3.54-2.44-8-3.5-6.98-0.75-14-0.5 0.63-1.08 2-1.5 13.82-2.52 26 4-2.63 1.98-6 1.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s40" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s41" d="m214.5 349.5q-21.43 15.48-48 16 22.82-5.9 43-18.5 3.64-1.12 5 2.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s42" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s43" d="m302.5 373.5q-14.74-16.73-37-19-4.55 0.25-9 1 25.3-10.24 43.5 11 2.85 2.91 2.5 7z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s44" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s45" d="m100.5 356.5q5.42 2.71 11 5.5-13.04 7.54-18.5 21.5-7.57-7.14-10.5-17 5.58 1.54 10 5.5 4.2 0.84 5.5-3.5 1.41-5.99 2.5-12z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s8" d="m83.5 394.5q-18.9-10.15-29.5-29-1.54-3.52-2-7 5.79 2.39 10 7 7.82 16.63 21.5 29z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s46" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s47" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s29" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s48" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s49" d="m283.5 385.5q8.71-1.27 11.5 7 1.22 2.9 1.5 6-3.2-3.47-6-7.5-3.78-2.55-7-5.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s50" d="m83.5 394.5q1.88-0.06 3 1.5-2.25 0.88-3-1.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s51" d="m258.5 392.5q3.51 0.41 0 2.5-2.33 1.93-5 2 2.61-2.28 5-4.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s52" d="m111.5 392.5q0.09-0.81 1-1 1.48 4.9 1 10-1-4.5-2-9z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="#131010"></rect>
|
||||
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<rect width="800" height="800" rx="160" fill="#fff"/>
|
||||
<path fill="#000" fill-rule="evenodd" d="
|
||||
M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z
|
||||
M282.65 282.65 V400 H400 V282.65 Z
|
||||
"/>
|
||||
<path fill="#000" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<rect width="800" height="800" rx="160" fill="#000"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="
|
||||
M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z
|
||||
M282.65 282.65 V400 H400 V282.65 Z
|
||||
"/>
|
||||
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="poolside-gradient" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6c5cff"/>
|
||||
<stop offset="1" stop-color="#3c2cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="13" fill="url(#poolside-gradient)"/>
|
||||
<path d="M13 32c0-10.5 8.5-19 19-19 10.49 0 19 8.5 19 19s-8.51 19-19 19c-10.5 0-19-8.5-19-19Z" fill="none" stroke="#fff" stroke-width="4"/>
|
||||
<path d="M16 24c8-4.1 17.1-.9 22.6 7.1 4.3-1.2 8.6.5 11 4.1M23.5 47.5 38 17.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 141.38 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.32 3.32 0 0 0 0-3.34l-9.62-16.67a3.34 3.34 0 0 0-2.89-1.67H82.23l-8.66-15A3.33 3.33 0 0 0 70.68-.02H51.43a3.33 3.33 0 0 0-2.88 1.67L32.19 29.98l-1.92 3.33H12.96a3.34 3.34 0 0 0-2.88 1.67L.45 51.66a3.32 3.32 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67a3.34 3.34 0 0 0 2.89 1.67h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32a3.34 3.34 0 0 0 2.89-1.67l9.62-16.67a3.32 3.32 0 0 0-.01-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { listModels } = vi.hoisted(() => ({ listModels: vi.fn() }));
|
||||
vi.mock("./lib/ollama-client", () => ({
|
||||
ollamaClient: { list: listModels },
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchConnectUrl,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("requests a desktop handoff after account creation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
signin_url:
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchConnectUrl()).resolves.toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationStatuses", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns desktop and launcher integration metadata", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getIntegrationStatuses()).resolves.toEqual([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClaudeDesktopAvailableModels", () => {
|
||||
afterEach(() => {
|
||||
listModels.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns installed local models while pruning remote entries", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "llama3.2:latest", digest: "local" },
|
||||
{
|
||||
name: "remote-placeholder",
|
||||
digest: "remote",
|
||||
remote_host: "https://ollama.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["llama3.2"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not request cloud models when they are unavailable to the user", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "qwen3:8b", digest: "local" },
|
||||
{ name: "deepseek-v4-flash:cloud", digest: "cached-cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cached-cloud" },
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the account cloud list in parallel when Cloud is available", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{ name: "glm-5.2", digest: "cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cloud" },
|
||||
{ name: "qwen3:8b", digest: "cloud-duplicate" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual([
|
||||
"qwen3:8b",
|
||||
"glm-5.2:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/models/cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps local models when the account cloud list fails", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,24 @@ export interface CloudStatusResponse {
|
||||
disabled: boolean;
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed?: boolean;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export type IntegrationStatuses = IntegrationStatus[];
|
||||
|
||||
export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/integrations`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch integration statuses: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
@@ -81,7 +99,9 @@ export async function fetchConnectUrl(): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
return data.signin_url;
|
||||
const connectUrl = new URL(data.signin_url);
|
||||
connectUrl.searchParams.set("launch", "true");
|
||||
return connectUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +181,7 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
// Add query if it's in the registry and not already in the list
|
||||
if (!exactMatch) {
|
||||
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
||||
const existsUpstream = !!result.digest && !result.error;
|
||||
const existsUpstream = result.exists;
|
||||
if (existsUpstream) {
|
||||
filteredModels.push(new Model({ model: query }));
|
||||
}
|
||||
@@ -176,6 +196,84 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaudeDesktopAvailableModels(
|
||||
includeCloudModels = false,
|
||||
): Promise<Model[]> {
|
||||
try {
|
||||
const [localResult, cloudResult] = await Promise.all([
|
||||
ollama.list(),
|
||||
includeCloudModels
|
||||
? fetch(`${API_BASE}/api/v1/models/cloud`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`cloud model list returned ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { models?: ModelResponse[] };
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to fetch cloud models:", error);
|
||||
return { models: [] };
|
||||
})
|
||||
: Promise.resolve({ models: [] as ModelResponse[] }),
|
||||
]);
|
||||
|
||||
const localModels = localResult.models.filter((model: ModelResponse) => {
|
||||
const response = model as ModelResponse & {
|
||||
remote_model?: string;
|
||||
remote_host?: string;
|
||||
};
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
return (
|
||||
!response.remote_model &&
|
||||
!response.remote_host &&
|
||||
!name.endsWith("cloud")
|
||||
);
|
||||
});
|
||||
const cloudModels = (cloudResult.models ?? []).map((model) => {
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
const tag = name.slice(name.lastIndexOf(":") + 1).toLowerCase();
|
||||
const explicitCloud =
|
||||
name.endsWith(":cloud") ||
|
||||
(name.includes(":") && tag.endsWith("-cloud"));
|
||||
return {
|
||||
...model,
|
||||
name: explicitCloud ? name : `${name}:cloud`,
|
||||
};
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...localModels, ...cloudModels]
|
||||
.filter((model: ModelResponse) => {
|
||||
const base = model.name
|
||||
.replace(/:latest$/, "")
|
||||
.replace(/:cloud$/, "");
|
||||
if (!base || seen.has(base)) return false;
|
||||
|
||||
const families = model.details?.families;
|
||||
const supported =
|
||||
!families ||
|
||||
families.length === 0 ||
|
||||
!families.every((family: string) =>
|
||||
family.toLowerCase().includes("bert"),
|
||||
);
|
||||
if (supported) seen.add(base);
|
||||
return supported;
|
||||
})
|
||||
.map(
|
||||
(model: ModelResponse) =>
|
||||
new Model({
|
||||
model: model.name.replace(/:latest$/, ""),
|
||||
digest: model.digest,
|
||||
modified_at: model.modified_at
|
||||
? new Date(model.modified_at)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(
|
||||
modelName: string,
|
||||
): Promise<ModelCapabilitiesResponse> {
|
||||
@@ -339,7 +437,7 @@ export async function deleteChat(chatId: string): Promise<void> {
|
||||
// Get upstream information for model staleness checking
|
||||
export async function getModelUpstreamInfo(
|
||||
model: Model,
|
||||
): Promise<{ digest?: string; pushTime: number; error?: string }> {
|
||||
): Promise<{ stale: boolean; exists: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
||||
method: "POST",
|
||||
@@ -353,22 +451,22 @@ export async function getModelUpstreamInfo(
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to check upstream digest for ${model.model}: ${response.status}`,
|
||||
`Failed to check upstream for ${model.model}: ${response.status}`,
|
||||
);
|
||||
return { pushTime: 0 };
|
||||
return { stale: false, exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.warn(`Upstream digest check: ${data.error}`);
|
||||
return { error: data.error, pushTime: 0 };
|
||||
console.warn(`Upstream check: ${data.error}`);
|
||||
return { stale: false, exists: false, error: data.error };
|
||||
}
|
||||
|
||||
return { digest: data.digest, pushTime: data.pushTime || 0 };
|
||||
return { stale: !!data.stale, exists: true };
|
||||
} catch (error) {
|
||||
console.warn(`Error checking model staleness:`, error);
|
||||
return { pushTime: 0 };
|
||||
return { stale: false, exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +504,33 @@ export async function* pullModel(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelRecommendation {
|
||||
model: string;
|
||||
description: string;
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
vram_bytes?: number;
|
||||
}
|
||||
|
||||
export interface ModelRecommendationsResponse {
|
||||
recommendations: ModelRecommendation[];
|
||||
}
|
||||
|
||||
export async function getModelRecommendations(): Promise<
|
||||
ModelRecommendation[]
|
||||
> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/experimental/model-recommendations`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch model recommendations: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const data: ModelRecommendationsResponse = await response.json();
|
||||
return data.recommendations || [];
|
||||
}
|
||||
|
||||
export async function getInferenceCompute(): Promise<InferenceComputeResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { Cog6ToothIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type AppSection = "apps" | "chat" | "settings";
|
||||
|
||||
export function AppNavigation({ current }: { current: AppSection }) {
|
||||
const itemClass = (section: AppSection) =>
|
||||
`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
current === section ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link to="/connect" className={itemClass("apps")} draggable={false}>
|
||||
<RectangleGroupIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Apps</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
mask={{ to: "/" }}
|
||||
className={itemClass("chat")}
|
||||
draggable={false}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span className="truncate">Chat</span>
|
||||
</Link>
|
||||
<Link to="/settings" className={itemClass("settings")} draggable={false}>
|
||||
<Cog6ToothIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar({ current }: { current: AppSection }) {
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col px-4 pb-4 select-none">
|
||||
<AppNavigation current={current} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -480,13 +480,15 @@ function ChatForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare attachments for submission
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments.map(
|
||||
(att) => ({
|
||||
// Prepare attachments for submission, excluding unsupported images
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments
|
||||
.filter(
|
||||
(att) => hasVisionCapability || !isImageFile(att.filename),
|
||||
)
|
||||
.map((att) => ({
|
||||
filename: att.filename,
|
||||
data: att.data || new Uint8Array(0), // Empty data for existing files
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const useWebSearch =
|
||||
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
||||
@@ -736,10 +738,17 @@ function ChatForm({
|
||||
)}
|
||||
{(message.attachments.length > 0 || message.fileErrors.length > 0) && (
|
||||
<div className="flex gap-2 overflow-x-auto px-3 pt pb-3 w-full scrollbar-hide">
|
||||
{message.attachments.map((attachment, index) => (
|
||||
{message.attachments.map((attachment, index) => {
|
||||
const isUnsupportedImage =
|
||||
!hasVisionCapability && isImageFile(attachment.filename);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="group flex items-center gap-2 py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors flex-shrink-0"
|
||||
className={`group flex items-center gap-2 py-2 px-3 rounded-lg transition-colors flex-shrink-0 ${
|
||||
isUnsupportedImage
|
||||
? "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
: "bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{isImageFile(attachment.filename) ? (
|
||||
<ImageThumbnail
|
||||
@@ -764,9 +773,16 @@ function ChatForm({
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 max-w-[150px] truncate">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className={`text-sm max-w-36 truncate ${isUnsupportedImage ? "text-red-700 dark:text-red-300" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
{isUnsupportedImage && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 opacity-75">
|
||||
This model does not support images
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
@@ -788,7 +804,8 @@ function ChatForm({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{message.fileErrors.map((fileError, index) => (
|
||||
<div
|
||||
key={`error-${index}`}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ChatIcon({ className = "h-5 w-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${className} fill-current`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,12 @@ import { getChat } from "@/api";
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { ChatsResponse } from "@/gotypes";
|
||||
import { CogIcon } from "@heroicons/react/24/outline";
|
||||
import { AppNavigation } from "@/components/AppSidebar";
|
||||
|
||||
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
}
|
||||
@@ -239,141 +238,100 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
[startEditing, handleDeleteChat],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4">Loading...</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4 text-red-500">Error loading chats</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col min-h-0 select-none">
|
||||
<nav
|
||||
aria-busy={isLoading || undefined}
|
||||
className="flex flex-1 flex-col min-h-0 select-none"
|
||||
>
|
||||
<header className="flex flex-col gap-0.5 px-4 pb-2">
|
||||
<Link
|
||||
href="/c/new"
|
||||
mask={{ to: "/" }}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${
|
||||
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
<span className="truncate">New Chat</span>
|
||||
</Link>
|
||||
{isWindows && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-300`}
|
||||
draggable={false}
|
||||
>
|
||||
<CogIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
)}
|
||||
<AppNavigation current="chat" />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
{error ? (
|
||||
<div className="px-2 pt-4 text-sm text-red-500">
|
||||
Error loading chats
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import {
|
||||
act,
|
||||
create,
|
||||
type ReactTestInstance,
|
||||
type ReactTestRenderer,
|
||||
} from "react-test-renderer";
|
||||
import {
|
||||
createRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
ClaudeDesktopModelsSettings,
|
||||
type ClaudeDesktopModelsSettingsHandle,
|
||||
} from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
vi.mock("@headlessui/react", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const original = await importOriginal<typeof import("@headlessui/react")>();
|
||||
type PopoverContextValue = {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
const PopoverContext = React.createContext<PopoverContextValue | null>(null);
|
||||
const usePopover = () => {
|
||||
const context = React.useContext(PopoverContext);
|
||||
if (!context) throw new Error("Popover components must be nested");
|
||||
return context;
|
||||
};
|
||||
|
||||
function TestPopover({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const context = {
|
||||
open,
|
||||
close: () => setOpen(false),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
};
|
||||
return (
|
||||
<PopoverContext.Provider value={context}>
|
||||
<div className={className}>{children}</div>
|
||||
</PopoverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPopoverButton({
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { open, toggle } = usePopover();
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-expanded={open}
|
||||
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
toggle();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPopoverPanel({
|
||||
anchor,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
anchor?: unknown;
|
||||
children: ReactNode | ((props: { close: () => void }) => ReactNode);
|
||||
}) {
|
||||
const { open, close } = usePopover();
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div {...props} data-anchor={JSON.stringify(anchor)}>
|
||||
{typeof children === "function" ? children({ close }) : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return Object.assign({}, original, {
|
||||
Popover: TestPopover,
|
||||
PopoverButton: TestPopoverButton,
|
||||
PopoverPanel: TestPopoverPanel,
|
||||
});
|
||||
});
|
||||
|
||||
const fableRoute = {
|
||||
routeId: "claude-fable-5",
|
||||
routeName: "Fable 5",
|
||||
};
|
||||
|
||||
function testStatus(model = "glm-5.2:cloud", running = false) {
|
||||
return {
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
autoMode: false,
|
||||
modelSource: "user" as const,
|
||||
mappings: [{ ...fableRoute, model }],
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: model === "glm-5.2:cloud",
|
||||
availability: "available" as const,
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: model === "kimi-k3:cloud",
|
||||
availability: "available" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function selectKimi(renderer: ReactTestRenderer) {
|
||||
await act(async () => {
|
||||
pickerButton(renderer).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findAllByProps({ role: "option" })[1].props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function pickerButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.props["aria-label"] === "Ollama model for Fable 5",
|
||||
);
|
||||
if (!button) throw new Error("Claude model picker button not found");
|
||||
return button;
|
||||
}
|
||||
|
||||
function actionButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find(
|
||||
(candidate) =>
|
||||
!candidate.props["aria-label"] &&
|
||||
candidate.props.className?.includes("flex-shrink-0"),
|
||||
);
|
||||
if (!button) throw new Error("Claude action button not found");
|
||||
return button;
|
||||
}
|
||||
|
||||
function textContent(node: ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === "string" ? child : textContent(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopModelsSettings interactions", () => {
|
||||
it("opens below without scrolling and disables auto mode for draft changes", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const focus = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
autoMode: true,
|
||||
modelSource: "user",
|
||||
mappings: [
|
||||
{
|
||||
routeId: "claude-fable-5",
|
||||
routeName: "Fable 5",
|
||||
model: "glm-5.2:cloud",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
autoMode: true,
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
autoMode: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) =>
|
||||
element.type === "input" ? { focus } : null,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const autoModeSwitch = () =>
|
||||
renderer!.root.findByProps({ role: "switch" });
|
||||
expect(autoModeSwitch().props.disabled).not.toBe(true);
|
||||
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
pickerButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
renderer!.root.findByProps({
|
||||
"data-anchor": JSON.stringify({
|
||||
to: "bottom end",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
}),
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
await act(async () => {
|
||||
const options = renderer!.root.findAllByProps({ role: "option" });
|
||||
options[1].props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(autoModeSwitch().props.disabled).toBe(true);
|
||||
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(
|
||||
renderer!.root
|
||||
.findAllByType("p")
|
||||
.some((node) =>
|
||||
node.children
|
||||
.join("")
|
||||
.includes(
|
||||
"Start or restart Claude to apply model changes before changing auto mode.",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for confirmation from live native state before restarting", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const apply = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: testStatus("glm-5.2:cloud", true),
|
||||
error:
|
||||
"Claude Desktop restart confirmation is required before changing its profile",
|
||||
restartConfirmationRequired: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: testStatus("kimi-k3:cloud", true),
|
||||
mappingsApplied: true,
|
||||
});
|
||||
const confirm = vi.fn(() => true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
applyClaudeDesktopMappings: apply,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Restart Claude Desktop? Any running task will stop.",
|
||||
);
|
||||
expect(apply).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ "claude-fable-5": "kimi-k3:cloud" },
|
||||
false,
|
||||
);
|
||||
expect(apply).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ "claude-fable-5": "kimi-k3:cloud" },
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("restores Auto mode when restart confirmation is canceled", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const runningStatus = {
|
||||
...testStatus("glm-5.2:cloud", true),
|
||||
autoMode: true,
|
||||
models: testStatus().models.map((model) => ({
|
||||
...model,
|
||||
autoMode: true,
|
||||
})),
|
||||
};
|
||||
const setAutoMode = vi.fn().mockResolvedValue({
|
||||
status: runningStatus,
|
||||
error:
|
||||
"Claude Desktop restart confirmation is required before changing its profile",
|
||||
restartConfirmationRequired: true,
|
||||
});
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
setClaudeDesktopAutoMode: setAutoMode,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={runningStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(Switch).props.onChange(false);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(setAutoMode).toHaveBeenCalledTimes(1);
|
||||
expect(setAutoMode).toHaveBeenCalledWith(false, false);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Restart Claude to change auto mode? Any running task will stop.",
|
||||
);
|
||||
expect(
|
||||
renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a stale focus refresh that finishes after apply", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
let focusHandler: (() => void) | undefined;
|
||||
let resolveRefresh:
|
||||
| ((status: ReturnType<typeof testStatus>) => void)
|
||||
| undefined;
|
||||
const staleRefresh = new Promise<ReturnType<typeof testStatus>>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === "focus") focusHandler = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
getClaudeDesktopStatus: vi.fn(() => staleRefresh),
|
||||
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
|
||||
status: testStatus("kimi-k3:cloud"),
|
||||
mappingsApplied: true,
|
||||
}),
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
focusHandler?.();
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
resolveRefresh?.(testStatus("glm-5.2:cloud"));
|
||||
await staleRefresh;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts committed mappings when launching Claude fails", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const onDraftChange = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
|
||||
status: testStatus("kimi-k3:cloud"),
|
||||
error:
|
||||
"Claude model mappings were saved, but Claude Desktop could not open",
|
||||
mappingsApplied: true,
|
||||
}),
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(false);
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the previous mappings when reset restart is canceled", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const currentStatus = testStatus("kimi-k3:cloud", true);
|
||||
const resetMappings = vi.fn().mockResolvedValue({
|
||||
status: currentStatus,
|
||||
restartConfirmationRequired: true,
|
||||
});
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
resetClaudeDesktopMappings: resetMappings,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={settingsRef}
|
||||
initialLocalModels={[]}
|
||||
initialStatus={currentStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let resetSucceeded = true;
|
||||
await act(async () => {
|
||||
resetSucceeded =
|
||||
(await settingsRef.current?.resetToDefaults()) ?? false;
|
||||
});
|
||||
|
||||
expect(resetSucceeded).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(resetMappings).toHaveBeenCalledWith(false);
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the native reset result and shows progress", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const initialStatus = {
|
||||
...testStatus("glm-5.2:cloud"),
|
||||
mappings: [
|
||||
{ ...fableRoute, model: "glm-5.2:cloud" },
|
||||
{
|
||||
routeId: "claude-sonnet-5",
|
||||
routeName: "Sonnet 5",
|
||||
model: "kimi-k3:cloud",
|
||||
},
|
||||
],
|
||||
};
|
||||
const resetStatus = {
|
||||
...initialStatus,
|
||||
mappings: [
|
||||
{ ...fableRoute },
|
||||
{
|
||||
routeId: "claude-sonnet-5",
|
||||
routeName: "Sonnet 5",
|
||||
model: "glm-5.2:cloud",
|
||||
},
|
||||
],
|
||||
};
|
||||
const resetResult = {
|
||||
status: resetStatus,
|
||||
mappingsApplied: true,
|
||||
};
|
||||
let resolveReset!: (result: typeof resetResult) => void;
|
||||
const resetRequestResult = new Promise<typeof resetResult>((resolve) => {
|
||||
resolveReset = resolve;
|
||||
});
|
||||
const resetMappings = vi.fn(() => resetRequestResult);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
resetClaudeDesktopMappings: resetMappings,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={settingsRef}
|
||||
initialLocalModels={[]}
|
||||
initialStatus={initialStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let resetRequest: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
resetRequest = settingsRef.current?.resetToDefaults();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(actionButton(renderer!).props.disabled).toBe(true);
|
||||
expect(textContent(actionButton(renderer!))).toContain("Resetting…");
|
||||
|
||||
resolveReset(resetResult);
|
||||
let resetSucceeded = false;
|
||||
await act(async () => {
|
||||
resetSucceeded = (await resetRequest) ?? false;
|
||||
});
|
||||
|
||||
expect(resetSucceeded).toBe(true);
|
||||
expect(resetMappings).toHaveBeenCalledWith(false);
|
||||
|
||||
const fable = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
const sonnet = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Sonnet 5",
|
||||
});
|
||||
expect(fable.findAllByType("span")[0].children.join("")).toBe(
|
||||
"Select a model",
|
||||
);
|
||||
expect(sonnet.findAllByType("span")[0].children.join("")).toBe(
|
||||
"glm-5.2:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ClaudeDesktopStatus } from "@/types/webview";
|
||||
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
const routes = [
|
||||
{ routeId: "claude-fable-5", routeName: "Fable 5" },
|
||||
{ routeId: "claude-opus-5", routeName: "Opus 5" },
|
||||
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
|
||||
{
|
||||
routeId: "claude-haiku-4-5-20251001",
|
||||
routeName: "Haiku 4.5",
|
||||
},
|
||||
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
|
||||
];
|
||||
|
||||
function status(
|
||||
overrides: Partial<ClaudeDesktopStatus> = {},
|
||||
): ClaudeDesktopStatus {
|
||||
return {
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
],
|
||||
mappings: routes.map((route, index) => ({
|
||||
...route,
|
||||
model: index === 0 ? "glm-5.2:cloud" : undefined,
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopModelsSettings", () => {
|
||||
it("labels model plan and account requirements in the picker", () => {
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
requiredPlan: "free",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
}),
|
||||
).toBe("Pro plan required");
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "unavailable",
|
||||
reason: "sign_in_required",
|
||||
requiredPlan: "free",
|
||||
}),
|
||||
).toBe("Sign in required");
|
||||
});
|
||||
|
||||
it("renders the five explicit Claude routes and an Ollama model picker", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Claude</h2>");
|
||||
for (const route of routes) {
|
||||
expect(html).toContain(route.routeName);
|
||||
expect(html).not.toContain(`>${route.routeId}<`);
|
||||
}
|
||||
expect((html.match(/aria-haspopup="listbox"/g) ?? []).length).toBe(5);
|
||||
expect(html).not.toContain('for="claude-route-');
|
||||
expect(html).toContain(
|
||||
"Choose which Ollama model Claude uses for each model option.",
|
||||
);
|
||||
expect(html).not.toContain("routing");
|
||||
expect(html).not.toContain("Built-in defaults");
|
||||
expect(html).not.toContain("Unassigned");
|
||||
expect(html).toContain("Select a model");
|
||||
expect(html).toContain("Start Claude");
|
||||
});
|
||||
|
||||
it("allows the same Ollama model to be assigned to multiple routes", () => {
|
||||
const shared = routes.map((route) => ({
|
||||
...route,
|
||||
model: "qwen3:8b",
|
||||
}));
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({ mappings: shared })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((html.match(/>qwen3:8b<\/span>/g) ?? []).length).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps an unavailable default visible with its access status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(">glm-5.2:cloud</span>");
|
||||
});
|
||||
|
||||
it("presents Start or Restart based on whether Claude is running", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({ configured: false, connected: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Start Claude");
|
||||
expect(html).not.toContain("Apply changes");
|
||||
|
||||
const runningHTML = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status({ running: true })} />,
|
||||
);
|
||||
expect(runningHTML).toContain("Restart Claude");
|
||||
expect(runningHTML).toContain("disabled");
|
||||
});
|
||||
|
||||
it("stays hidden until Claude has been enabled once", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status({ used: false })} />,
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,701 @@
|
||||
import { getClaudeDesktopAvailableModels } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Description, Field, Label } from "@/components/ui/fieldset";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { claudeDesktopRecoveryMessage } from "@/lib/claudeDesktop";
|
||||
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
|
||||
import type {
|
||||
ClaudeDesktopActionResult,
|
||||
ClaudeDesktopMappingStatus,
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface ClaudeDesktopModelsSettingsHandle {
|
||||
resetToDefaults: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopModelsSettingsProps {
|
||||
initialStatus?: ClaudeDesktopStatus;
|
||||
initialLocalModels?: string[];
|
||||
initialCloudModels?: string[];
|
||||
includeCloudModels?: boolean;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
showSectionHeading?: boolean;
|
||||
}
|
||||
|
||||
const fallbackRoutes: ClaudeDesktopMappingStatus[] = [
|
||||
{ routeId: "claude-fable-5", routeName: "Fable 5" },
|
||||
{ routeId: "claude-opus-5", routeName: "Opus 5" },
|
||||
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
|
||||
{
|
||||
routeId: "claude-haiku-4-5-20251001",
|
||||
routeName: "Haiku 4.5",
|
||||
},
|
||||
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
|
||||
];
|
||||
|
||||
function isInvalidModelName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase().replace(/[-:]+/g, " ");
|
||||
return normalized === "ollama cloud";
|
||||
}
|
||||
|
||||
function visibleModels(
|
||||
status: ClaudeDesktopStatus,
|
||||
): ClaudeDesktopModelStatus[] {
|
||||
return (status.models ?? []).filter(
|
||||
(model) => !isInvalidModelName(model.name) && model.reason !== "cloud_off",
|
||||
);
|
||||
}
|
||||
|
||||
function modelIsAvailable(model: ClaudeDesktopModelStatus): boolean {
|
||||
return !model.availability || model.availability === "available";
|
||||
}
|
||||
|
||||
function initialMappings(
|
||||
status: ClaudeDesktopStatus,
|
||||
): ClaudeDesktopMappingStatus[] {
|
||||
const models = visibleModels(status);
|
||||
const known = new Set(models.map((model) => model.name));
|
||||
const available = new Set(
|
||||
models.filter(modelIsAvailable).map((model) => model.name),
|
||||
);
|
||||
const routes = (
|
||||
status.mappings?.length ? status.mappings : fallbackRoutes
|
||||
).map((route) => ({ ...route }));
|
||||
|
||||
if (!status.mappings?.length) {
|
||||
const selected = models.filter(
|
||||
(model) => model.selected && available.has(model.name),
|
||||
);
|
||||
selected.slice(0, routes.length).forEach((model, index) => {
|
||||
routes[index].model = model.name;
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.model && !known.has(route.model)) route.model = undefined;
|
||||
}
|
||||
if (!routes.some((route) => route.model)) {
|
||||
const first = models.find(modelIsAvailable);
|
||||
if (first && routes.length > 0) routes[0].model = first.name;
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function mappingsEqual(
|
||||
left: ClaudeDesktopMappingStatus[],
|
||||
right: ClaudeDesktopMappingStatus[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every(
|
||||
(route, index) =>
|
||||
route.routeId === right[index]?.routeId &&
|
||||
(route.model ?? "") === (right[index]?.model ?? ""),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function mappingRecord(
|
||||
mappings: ClaudeDesktopMappingStatus[],
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
mappings
|
||||
.filter((route) => route.model)
|
||||
.map((route) => [route.routeId, route.model ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatModelList(names: string[]): string {
|
||||
if (names.length < 2) return names[0] ?? "";
|
||||
if (names.length === 2) return `${names[0]} or ${names[1]}`;
|
||||
return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
interface ClaudeModelPickerProps {
|
||||
id: string;
|
||||
routeName: string;
|
||||
value?: string;
|
||||
models: ClaudeDesktopModelStatus[];
|
||||
disabled: boolean;
|
||||
onChange: (model: string) => void;
|
||||
}
|
||||
|
||||
function ClaudeModelPicker({
|
||||
id,
|
||||
routeName,
|
||||
value,
|
||||
models,
|
||||
disabled,
|
||||
onChange,
|
||||
}: ClaudeModelPickerProps) {
|
||||
return (
|
||||
<Popover className="relative min-w-0">
|
||||
<PopoverButton
|
||||
id={id}
|
||||
aria-label={`Ollama model for ${routeName}`}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg bg-neutral-50 px-3 py-1.5 text-left text-sm text-neutral-800 outline-none ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-700 dark:text-neutral-100 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${value ? "" : "text-neutral-400"}`}
|
||||
>
|
||||
{value || "Select a model"}
|
||||
</span>
|
||||
<ChevronUpDownIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel
|
||||
anchor={{ to: "bottom end", gap: 8, padding: 8 }}
|
||||
className="z-50 flex w-[var(--button-width)] min-w-64 flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
{({ close }) => (
|
||||
<ClaudeModelPickerOptions
|
||||
routeName={routeName}
|
||||
value={value}
|
||||
models={models}
|
||||
onChange={(model) => {
|
||||
onChange(model);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeModelPickerOptions({
|
||||
routeName,
|
||||
value,
|
||||
models,
|
||||
onChange,
|
||||
}: Pick<
|
||||
ClaudeModelPickerProps,
|
||||
"routeName" | "value" | "models" | "onChange"
|
||||
>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.displayName.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Find model..."
|
||||
aria-label={`Find model for ${routeName}`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div role="listbox" className="min-h-0 overflow-y-auto py-1">
|
||||
{filteredModels.map((model) => {
|
||||
const available = modelIsAvailable(model);
|
||||
const statusLabel = claudeDesktopModelStatusLabel(model);
|
||||
const selected = value === model.name;
|
||||
return (
|
||||
<button
|
||||
key={model.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
disabled={!available}
|
||||
onClick={() => onChange(model.name)}
|
||||
className="flex w-full cursor-pointer items-start gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60"
|
||||
>
|
||||
<span className="mt-0.5 h-4 w-4 flex-shrink-0">
|
||||
{selected && <CheckIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{model.displayName}</span>
|
||||
{statusLabel && (
|
||||
<span className="mt-0.5 block truncate text-xs text-neutral-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-3 py-2 text-neutral-400">No models found</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClaudeDesktopModelsSettings = forwardRef<
|
||||
ClaudeDesktopModelsSettingsHandle,
|
||||
ClaudeDesktopModelsSettingsProps
|
||||
>(function ClaudeDesktopModelsSettings(
|
||||
{
|
||||
initialStatus,
|
||||
initialLocalModels,
|
||||
initialCloudModels,
|
||||
includeCloudModels = false,
|
||||
onDraftChange,
|
||||
showSectionHeading = true,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [status, setStatus] = useState<ClaudeDesktopStatus | null>(
|
||||
initialStatus ?? null,
|
||||
);
|
||||
const [models, setModels] = useState<ClaudeDesktopModelStatus[]>(() =>
|
||||
initialStatus ? visibleModels(initialStatus) : [],
|
||||
);
|
||||
const [mappings, setMappings] = useState<ClaudeDesktopMappingStatus[]>(() =>
|
||||
initialStatus ? initialMappings(initialStatus) : [],
|
||||
);
|
||||
const [savedMappings, setSavedMappings] = useState<
|
||||
ClaudeDesktopMappingStatus[]
|
||||
>(() => (initialStatus ? initialMappings(initialStatus) : []));
|
||||
const [localModels, setLocalModels] = useState<string[]>(
|
||||
initialLocalModels ?? [],
|
||||
);
|
||||
const [accountCloudModels, setAccountCloudModels] = useState<string[]>(
|
||||
initialCloudModels ?? [],
|
||||
);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resettingMappings, setResettingMappings] = useState(false);
|
||||
const [autoModeApplying, setAutoModeApplying] = useState(false);
|
||||
const [autoModeOverride, setAutoModeOverride] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const draftRef = useRef({ mappings, savedMappings });
|
||||
const statusRequestRef = useRef(0);
|
||||
const operationInFlightRef = useRef(false);
|
||||
draftRef.current = { mappings, savedMappings };
|
||||
|
||||
const applyStatus = useCallback(
|
||||
(next: ClaudeDesktopStatus, preserveDraft = false) => {
|
||||
const nextMappings = initialMappings(next);
|
||||
const draft = draftRef.current;
|
||||
const keepDraft =
|
||||
preserveDraft && !mappingsEqual(draft.mappings, draft.savedMappings);
|
||||
setStatus(next);
|
||||
setModels(visibleModels(next));
|
||||
if (!keepDraft) {
|
||||
setMappings(nextMappings);
|
||||
setSavedMappings(nextMappings);
|
||||
}
|
||||
setError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!window.getClaudeDesktopStatus) return;
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const next = await window.getClaudeDesktopStatus();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyStatus(next, true);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
}
|
||||
}
|
||||
}, [applyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStatus) void refreshStatus();
|
||||
const handleFocus = () => void refreshStatus();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, [initialStatus, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocalModels || !status?.used) return;
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
void getClaudeDesktopAvailableModels(includeCloudModels)
|
||||
.then((installed) => {
|
||||
if (!cancelled) {
|
||||
setLocalModels(installed.map((model) => model.model));
|
||||
setAccountCloudModels(
|
||||
installed
|
||||
.filter((model) => model.isCloud())
|
||||
.map((model) => model.model),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Ollama could not load your models.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [includeCloudModels, initialLocalModels, status?.used]);
|
||||
|
||||
const catalogModels = useMemo(() => {
|
||||
const current = new Set(models.map((model) => model.name));
|
||||
const installed: ClaudeDesktopModelStatus[] = localModels
|
||||
.filter((name) => !current.has(name) && !isInvalidModelName(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: false,
|
||||
availability: "available",
|
||||
}));
|
||||
return [...models, ...installed];
|
||||
}, [localModels, models]);
|
||||
|
||||
const hasDraftChanges = !mappingsEqual(mappings, savedMappings);
|
||||
const assignedModels = mappings
|
||||
.map((route) => route.model)
|
||||
.filter((model): model is string => Boolean(model));
|
||||
const hasInvalidMapping = assignedModels.some((name) => {
|
||||
const model = catalogModels.find((candidate) => candidate.name === name);
|
||||
return !model || !modelIsAvailable(model);
|
||||
});
|
||||
const busy = applying || resettingMappings || autoModeApplying;
|
||||
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasDraftChanges);
|
||||
}, [hasDraftChanges, onDraftChange]);
|
||||
|
||||
const updateMapping = (routeId: string, model: string) => {
|
||||
setError(null);
|
||||
setMappings((current) =>
|
||||
current.map((route) =>
|
||||
route.routeId === routeId
|
||||
? { ...route, model: model || undefined }
|
||||
: route,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const runMappingAction = useCallback(
|
||||
async (
|
||||
action: (restartConfirmed: boolean) => Promise<ClaudeDesktopActionResult>,
|
||||
failureMessage: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
let result = await action(false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyStatus(result.status, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restart Claude Desktop? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
result = await action(true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
applyStatus(result.status, !result.mappingsApplied);
|
||||
setError(result.error);
|
||||
return Boolean(result.mappingsApplied);
|
||||
}
|
||||
applyStatus(result.status);
|
||||
return true;
|
||||
} catch {
|
||||
setError(failureMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const applyChanges = async () => {
|
||||
const applyMappings = window.applyClaudeDesktopMappings;
|
||||
if (!applyMappings) {
|
||||
setError(
|
||||
"Claude routing settings are available in the Ollama macOS app.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (assignedModels.length === 0) {
|
||||
setError("Choose at least one Ollama model for Claude.");
|
||||
return;
|
||||
}
|
||||
if (hasInvalidMapping) {
|
||||
setError("Choose models available to your account and device.");
|
||||
return;
|
||||
}
|
||||
if (operationInFlightRef.current) return;
|
||||
|
||||
const mappingsToApply = mappingRecord(mappings);
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
await runMappingAction(
|
||||
(restartConfirmed) => applyMappings(mappingsToApply, restartConfirmed),
|
||||
"Ollama could not apply the Claude model mappings.",
|
||||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoMode = async (checked: boolean) => {
|
||||
if (!window.setClaudeDesktopAutoMode) {
|
||||
setError("Auto mode is available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setAutoModeOverride(checked);
|
||||
setAutoModeApplying(true);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
let result = await window.setClaudeDesktopAutoMode(checked, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyStatus(result.status, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restart Claude to change auto mode? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
result = await window.setClaudeDesktopAutoMode(checked, true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
applyStatus(result.status);
|
||||
if (result.error) setError(result.error);
|
||||
} catch {
|
||||
setError("Ollama could not update Claude auto mode.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setAutoModeOverride(null);
|
||||
setAutoModeApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaults = useCallback(async (): Promise<boolean> => {
|
||||
if (operationInFlightRef.current) return false;
|
||||
|
||||
const resetMappings = window.resetClaudeDesktopMappings;
|
||||
if (!resetMappings) {
|
||||
setError("Ollama could not reset the Claude model mappings.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setResettingMappings(true);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
return await runMappingAction(
|
||||
resetMappings,
|
||||
"Ollama could not reset the Claude model mappings.",
|
||||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setResettingMappings(false);
|
||||
}
|
||||
}, [runMappingAction]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
|
||||
|
||||
if (!status?.supported || !status.used) return null;
|
||||
|
||||
const autoModeModelNames = Array.from(
|
||||
new Set([
|
||||
...models.filter((model) => model.autoMode).map((model) => model.name),
|
||||
...accountCloudModels,
|
||||
]),
|
||||
);
|
||||
const autoModeModelSet = new Set(autoModeModelNames);
|
||||
const autoModeAvailable =
|
||||
!hasDraftChanges &&
|
||||
assignedModels.length > 0 &&
|
||||
assignedModels.some((name) => autoModeModelSet.has(name));
|
||||
const autoMode = autoModeAvailable
|
||||
? (autoModeOverride ?? status.autoMode ?? false)
|
||||
: (status.autoMode ?? false);
|
||||
const autoModeDescription = hasDraftChanges
|
||||
? "Start or restart Claude to apply model changes before changing auto mode."
|
||||
: autoModeAvailable
|
||||
? "Let Claude decide when to ask before making changes."
|
||||
: accountCloudModels.length > 0
|
||||
? "Select a cloud model from Ollama.com to use auto mode."
|
||||
: autoModeModelNames.length > 0
|
||||
? `Select one of ${formatModelList(autoModeModelNames)} to use auto mode.`
|
||||
: "Auto mode needs a cloud model available to your Ollama.com account.";
|
||||
|
||||
const guidance =
|
||||
claudeDesktopRecoveryMessage(status.error, error) ??
|
||||
(hasDraftChanges && status.running
|
||||
? "Restarting Claude will stop any running task."
|
||||
: null);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={showSectionHeading ? undefined : "Claude settings"}
|
||||
className="space-y-2"
|
||||
>
|
||||
{showSectionHeading && (
|
||||
<h2
|
||||
id="apps-settings-heading"
|
||||
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Apps
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
aria-labelledby="claude-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<img
|
||||
src="/launch-icons/claude.svg"
|
||||
alt=""
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2
|
||||
id="claude-settings-heading"
|
||||
className="text-sm font-medium text-neutral-900 dark:text-white"
|
||||
>
|
||||
Claude
|
||||
</h2>
|
||||
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
|
||||
Choose which Ollama model Claude uses for each model option.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={applyChanges}
|
||||
disabled={
|
||||
busy ||
|
||||
assignedModels.length === 0 ||
|
||||
hasInvalidMapping ||
|
||||
(status.running && !hasDraftChanges)
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{(applying || resettingMappings) && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{resettingMappings
|
||||
? "Resetting…"
|
||||
: applying
|
||||
? status.running
|
||||
? "Restarting…"
|
||||
: "Starting…"
|
||||
: status.running
|
||||
? "Restart Claude"
|
||||
: "Start Claude"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full max-w-xl space-y-1">
|
||||
{mappings.map((mapping) => (
|
||||
<div
|
||||
key={mapping.routeId}
|
||||
className="relative grid min-h-12 grid-cols-[5.5rem_3.75rem_minmax(0,1fr)] items-center gap-2 py-1 max-sm:grid-cols-1 max-sm:gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{mapping.routeName}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
aria-hidden="true"
|
||||
className="absolute left-[6.6625rem] h-4 w-4 -translate-x-1/2 text-neutral-300 dark:text-neutral-500 max-sm:hidden"
|
||||
/>
|
||||
<div className="col-start-3 w-2/3 min-w-0 max-sm:col-start-auto max-sm:w-full">
|
||||
<ClaudeModelPicker
|
||||
id={`claude-route-${mapping.routeId}`}
|
||||
routeName={mapping.routeName}
|
||||
value={mapping.model ?? ""}
|
||||
disabled={busy || modelsLoading}
|
||||
models={catalogModels}
|
||||
onChange={(model) =>
|
||||
updateMapping(mapping.routeId, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Field className="mt-3 w-full max-w-xl border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Label>Enable auto mode</Label>
|
||||
<Description>{autoModeDescription}</Description>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoMode}
|
||||
disabled={busy || !autoModeAvailable}
|
||||
onChange={(checked) => void toggleAutoMode(checked)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{guidance && (
|
||||
<p
|
||||
role={error || status.error ? "alert" : "status"}
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{guidance}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { CodexConnectedIntro } from "./CodexConnectedIntro";
|
||||
|
||||
vi.mock("@headlessui/react", () => {
|
||||
const Container = ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: Container,
|
||||
DialogPanel: Container,
|
||||
DialogTitle: Container,
|
||||
Description: Container,
|
||||
};
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("hands Continue to the connection flow, like Claude's intro", async () => {
|
||||
const done = vi.fn();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
let renderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<CodexConnectedIntro onDone={done} />);
|
||||
});
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
const button = renderer!.root.findByType("button");
|
||||
expect(button.children).toEqual(["Continue"]);
|
||||
await act(async () => button.props.onClick());
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Description,
|
||||
} from "@headlessui/react";
|
||||
|
||||
export function CodexConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<Dialog open onClose={() => {}} className="relative z-50">
|
||||
<div
|
||||
className="claude-connected-backdrop fixed inset-0 bg-black/20 dark:bg-black/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto p-6">
|
||||
<DialogPanel className="claude-connected-dialog relative max-h-full w-full max-w-md overflow-y-auto rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10 dark:bg-neutral-800 dark:ring-white/10">
|
||||
<img
|
||||
src="/chatgpt-connected.png"
|
||||
alt="Ollama models alongside OpenAI models in the ChatGPT Codex model picker"
|
||||
width={1172}
|
||||
height={1084}
|
||||
className="h-auto w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<DialogTitle className="font-rounded text-lg font-medium leading-6 text-neutral-950 dark:text-neutral-100">
|
||||
Use Ollama models in ChatGPT
|
||||
</DialogTitle>
|
||||
<Description className="mt-2 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Click Continue to open ChatGPT. In Codex mode, choose an Ollama
|
||||
model from the model picker for your task.
|
||||
</Description>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
onClick={onDone}
|
||||
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:bg-white dark:hover:bg-neutral-100"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
CodexDesktopModelStatus,
|
||||
CodexDesktopModelsSettings as ModelsSettings,
|
||||
CodexDesktopModelsSettingsResult,
|
||||
CodexDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
MagnifyingGlassIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface CodexDesktopModelsSettingsHandle {
|
||||
resetToDefaults: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface CodexDesktopModelsSettingsProps {
|
||||
initialSettings?: ModelsSettings;
|
||||
accountKey?: string;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
}
|
||||
|
||||
const CHATGPT_OPEN_POLL_INTERVAL_MS = 250;
|
||||
const CHATGPT_OPEN_TIMEOUT_MS = 30_000;
|
||||
|
||||
function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function waitForChatGPTToOpen(): Promise<
|
||||
CodexDesktopStatus | null | undefined
|
||||
> {
|
||||
const getStatus = window.getCodexDesktopStatus;
|
||||
if (!getStatus) return undefined;
|
||||
|
||||
const deadline = Date.now() + CHATGPT_OPEN_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
if (status.running) return status;
|
||||
} catch {
|
||||
// ChatGPT may be between processes during a restart. Keep checking until
|
||||
// it opens or the launch timeout expires.
|
||||
}
|
||||
await wait(CHATGPT_OPEN_POLL_INTERVAL_MS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectionsEqual(
|
||||
left: string[] | null | undefined,
|
||||
right: string[] | null | undefined,
|
||||
): boolean {
|
||||
left ??= [];
|
||||
right ??= [];
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((model, index) => model === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSettings(
|
||||
settings: ModelsSettings & {
|
||||
selected?: string[] | null;
|
||||
available?: string[] | null;
|
||||
models?: CodexDesktopModelStatus[] | null;
|
||||
},
|
||||
): ModelsSettings {
|
||||
const available = settings.available ?? [];
|
||||
return {
|
||||
...settings,
|
||||
usesDefaults: settings.usesDefaults ?? false,
|
||||
selected: settings.selected ?? [],
|
||||
available,
|
||||
models:
|
||||
settings.models ??
|
||||
available.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: settings.selected?.includes(name) ?? false,
|
||||
availability: "available" as const,
|
||||
})),
|
||||
maxModels: settings.maxModels || 5,
|
||||
};
|
||||
}
|
||||
|
||||
function modelIsAvailable(model: CodexDesktopModelStatus): boolean {
|
||||
return !model.availability || model.availability === "available";
|
||||
}
|
||||
|
||||
function modelCanBeSelected(model: CodexDesktopModelStatus): boolean {
|
||||
return model.recommended || modelIsAvailable(model);
|
||||
}
|
||||
|
||||
function modelStatusLabel(model: CodexDesktopModelStatus): string | null {
|
||||
switch (model.reason) {
|
||||
case "cloud_off":
|
||||
return "Cloud models are off";
|
||||
case "sign_in_required":
|
||||
return "Sign in required";
|
||||
case "upgrade_required":
|
||||
return model.requiredPlan
|
||||
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
|
||||
: "Upgrade required";
|
||||
case "verification_unavailable":
|
||||
return "Access unavailable";
|
||||
case "model_not_installed":
|
||||
return "Not installed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ModelOptions({
|
||||
models,
|
||||
selected,
|
||||
maxModels,
|
||||
onToggle,
|
||||
}: {
|
||||
models: CodexDesktopModelStatus[];
|
||||
selected: string[];
|
||||
maxModels: number;
|
||||
onToggle: (model: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = models.filter((model) =>
|
||||
`${model.displayName} ${model.name}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [normalizedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex < 0) return;
|
||||
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const optionIsDisabled = (model: CodexDesktopModelStatus) =>
|
||||
!modelCanBeSelected(model) ||
|
||||
(!selectedSet.has(model.name) && selected.length >= maxModels);
|
||||
|
||||
const moveHighlight = (direction: -1 | 1) => {
|
||||
setHighlightedIndex((current) => {
|
||||
if (filtered.length === 0) return -1;
|
||||
const start = current < 0 ? (direction === 1 ? -1 : 0) : current;
|
||||
for (let offset = 1; offset <= filtered.length; offset += 1) {
|
||||
const candidate =
|
||||
(start + direction * offset + filtered.length) % filtered.length;
|
||||
if (!optionIsDisabled(filtered[candidate])) return candidate;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (
|
||||
event.key === "Enter" &&
|
||||
highlightedIndex >= 0 &&
|
||||
highlightedIndex < filtered.length
|
||||
) {
|
||||
event.preventDefault();
|
||||
onToggle(filtered[highlightedIndex].name);
|
||||
}
|
||||
}}
|
||||
placeholder="Find model..."
|
||||
aria-label="Find ChatGPT model"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="chatgpt-model-options-listbox"
|
||||
aria-activedescendant={
|
||||
highlightedIndex >= 0
|
||||
? `chatgpt-model-option-${highlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="chatgpt-model-options-listbox"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
className="min-h-0 overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.map((model, index) => {
|
||||
const checked = selectedSet.has(model.name);
|
||||
const disabled = optionIsDisabled(model);
|
||||
const statusLabel = modelStatusLabel(model);
|
||||
return (
|
||||
<button
|
||||
key={model.name}
|
||||
id={`chatgpt-model-option-${index}`}
|
||||
ref={(element) => {
|
||||
optionRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(model.name)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60 ${
|
||||
highlightedIndex === index
|
||||
? "bg-neutral-100 dark:bg-neutral-700/60"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="h-4 w-4 shrink-0">
|
||||
{checked && <CheckIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{model.displayName}</span>
|
||||
{statusLabel && (
|
||||
<span className="mt-0.5 block truncate text-xs text-neutral-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-neutral-400">No models found</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CodexDesktopModelsSettings = forwardRef<
|
||||
CodexDesktopModelsSettingsHandle,
|
||||
CodexDesktopModelsSettingsProps
|
||||
>(function CodexDesktopModelsSettings(
|
||||
{ initialSettings, accountKey, onDraftChange },
|
||||
ref,
|
||||
) {
|
||||
const normalizedInitialSettings = initialSettings
|
||||
? normalizeSettings(initialSettings)
|
||||
: null;
|
||||
const [settings, setSettings] = useState<ModelsSettings | null>(
|
||||
normalizedInitialSettings,
|
||||
);
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
normalizedInitialSettings?.selected ?? [],
|
||||
);
|
||||
const [saved, setSaved] = useState<string[]>(
|
||||
normalizedInitialSettings?.selected ?? [],
|
||||
);
|
||||
const [loading, setLoading] = useState(!initialSettings);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [launchAction, setLaunchAction] = useState<"start" | "restart">(
|
||||
"start",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const draftRef = useRef({ selected, saved });
|
||||
const accountKeyRef = useRef(accountKey);
|
||||
const statusRequestRef = useRef(0);
|
||||
const operationInFlightRef = useRef(false);
|
||||
draftRef.current = { selected, saved };
|
||||
|
||||
const applyResult = useCallback(
|
||||
(result: CodexDesktopModelsSettingsResult, preserveDraft = false) => {
|
||||
const nextSettings = normalizeSettings(result.settings);
|
||||
const keepDraft =
|
||||
preserveDraft &&
|
||||
!selectionsEqual(draftRef.current.selected, draftRef.current.saved);
|
||||
setSettings(nextSettings);
|
||||
if (!keepDraft) {
|
||||
setSelected(nextSettings.selected);
|
||||
setSaved(nextSettings.selected);
|
||||
}
|
||||
setError(result.error ?? null);
|
||||
setWarning(result.warning ?? null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!window.getCodexDesktopModelsSettings) {
|
||||
setError("ChatGPT model settings are unavailable in this Ollama build.");
|
||||
setWarning(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const result = await window.getCodexDesktopModelsSettings();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyResult(result, true);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not load the ChatGPT model settings.");
|
||||
setWarning(null);
|
||||
}
|
||||
} finally {
|
||||
if (request === statusRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings) void refresh();
|
||||
const onFocus = () => void refresh();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [initialSettings, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountKeyRef.current === accountKey) return;
|
||||
accountKeyRef.current = accountKey;
|
||||
void refresh();
|
||||
}, [accountKey, refresh]);
|
||||
|
||||
const hasChanges = !selectionsEqual(selected, saved);
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasChanges);
|
||||
}, [hasChanges, onDraftChange]);
|
||||
|
||||
const maxModels = settings?.maxModels ?? 5;
|
||||
const models = useMemo(() => {
|
||||
const catalog = [...(settings?.models ?? [])];
|
||||
const known = new Set(catalog.map((model) => model.name));
|
||||
for (const name of selected) {
|
||||
if (known.has(name)) continue;
|
||||
known.add(name);
|
||||
catalog.push({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: true,
|
||||
availability: "unknown",
|
||||
reason: "verification_unavailable",
|
||||
});
|
||||
}
|
||||
return catalog;
|
||||
}, [selected, settings?.models]);
|
||||
const displayNames = useMemo(
|
||||
() => new Map(models.map((model) => [model.name, model.displayName])),
|
||||
[models],
|
||||
);
|
||||
|
||||
const toggleModel = (model: string) => {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setSelected((current) => {
|
||||
if (current.includes(model)) {
|
||||
return current.filter((name) => name !== model);
|
||||
}
|
||||
if (current.length >= maxModels) return current;
|
||||
return [...current, model];
|
||||
});
|
||||
};
|
||||
|
||||
const applyChanges = async () => {
|
||||
if (!window.applyCodexDesktopModels) {
|
||||
setError("ChatGPT model settings are available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
setError("Choose at least one model for ChatGPT.");
|
||||
return;
|
||||
}
|
||||
if (operationInFlightRef.current) return;
|
||||
|
||||
setApplying(true);
|
||||
setLaunchAction(settings?.running ? "restart" : "start");
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
const modelsToApply =
|
||||
!hasChanges && settings?.usesDefaults ? [] : selected;
|
||||
let result = await window.applyCodexDesktopModels(modelsToApply, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyResult(result, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
result.settings.connected
|
||||
? "Restart ChatGPT to update Ollama models? Any running task will stop."
|
||||
: "Restart ChatGPT to add Ollama models? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
result = await window.applyCodexDesktopModels(modelsToApply, true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
setError(result.error);
|
||||
setWarning(result.warning ?? null);
|
||||
return;
|
||||
}
|
||||
applyResult(result);
|
||||
const openedStatus = await waitForChatGPTToOpen();
|
||||
if (openedStatus === null) {
|
||||
setError("ChatGPT is taking longer than expected to open. Try again.");
|
||||
return;
|
||||
}
|
||||
if (openedStatus) {
|
||||
setSettings((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
installed: openedStatus.installed,
|
||||
connected: openedStatus.connected,
|
||||
running: openedStatus.running,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setError("Ollama could not apply the ChatGPT models.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaults = useCallback(async (): Promise<boolean> => {
|
||||
if (operationInFlightRef.current) return false;
|
||||
|
||||
const resetModels = window.resetCodexDesktopModels;
|
||||
if (!resetModels) {
|
||||
setError("Ollama could not reset the ChatGPT models.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setResetting(true);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
const result = await resetModels();
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
setError(result.error);
|
||||
setWarning(result.warning ?? null);
|
||||
return false;
|
||||
}
|
||||
applyResult(result);
|
||||
return true;
|
||||
} catch {
|
||||
setError("Ollama could not reset the ChatGPT models.");
|
||||
return false;
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setResetting(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
|
||||
|
||||
if (!settings?.supported && !loading && !error) return null;
|
||||
|
||||
const busy = applying || resetting;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-labelledby="chatgpt-model-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<img
|
||||
src="/launch-icons/codex.svg"
|
||||
alt=""
|
||||
className="h-5 w-5 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/launch-icons/codex-dark.svg"
|
||||
alt=""
|
||||
className="hidden h-5 w-5 dark:block"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2
|
||||
id="chatgpt-model-settings-heading"
|
||||
className="text-sm font-medium text-neutral-900 dark:text-white"
|
||||
>
|
||||
ChatGPT
|
||||
</h2>
|
||||
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
|
||||
Choose up to {maxModels} Ollama models to use in ChatGPT.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => void applyChanges()}
|
||||
disabled={loading || busy || selected.length === 0}
|
||||
>
|
||||
{applying && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{applying
|
||||
? launchAction === "restart"
|
||||
? "Restarting…"
|
||||
: "Starting…"
|
||||
: settings?.running
|
||||
? hasChanges
|
||||
? "Save & restart ChatGPT"
|
||||
: "Restart ChatGPT"
|
||||
: hasChanges
|
||||
? "Save & start ChatGPT"
|
||||
: "Start ChatGPT"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full max-w-xl">
|
||||
<Popover className="relative w-full">
|
||||
<div
|
||||
data-testid="chatgpt-model-picker"
|
||||
className="relative flex min-h-10 w-full flex-wrap items-center gap-2 rounded-lg bg-neutral-50 p-2 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<PopoverButton
|
||||
aria-label="Add ChatGPT model"
|
||||
disabled={loading || busy}
|
||||
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Choose ChatGPT models</span>
|
||||
</PopoverButton>
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{selected.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="pointer-events-none inline-flex max-w-full items-stretch overflow-hidden rounded-md bg-neutral-200/70 text-sm text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
|
||||
>
|
||||
<span className="min-w-0 py-1 pl-2 pr-1">
|
||||
<span className="block truncate">
|
||||
{displayNames.get(model) ?? model}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${model}`}
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleModel(model);
|
||||
}}
|
||||
className="pointer-events-auto inline-flex shrink-0 items-center px-1.5 hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50 dark:hover:bg-neutral-500"
|
||||
>
|
||||
<XMarkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selected.length === 0 && (
|
||||
<span className="px-1 py-1 text-sm text-neutral-400">
|
||||
{loading ? "Loading models…" : "Select models"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PopoverPanel
|
||||
anchor={{ to: "bottom start", gap: 8, padding: 8 }}
|
||||
data-testid="chatgpt-model-options"
|
||||
className="z-50 flex w-[var(--button-width)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
<ModelOptions
|
||||
models={models}
|
||||
selected={selected}
|
||||
maxModels={maxModels}
|
||||
onToggle={toggleModel}
|
||||
/>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{warning && (
|
||||
<p
|
||||
role="status"
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||