mirror of
https://github.com/ollama/ollama.git
synced 2026-02-27 12:36:54 -05:00
Compare commits
433 Commits
v0.12.7-ci
...
pdevine/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
857cffd22a | ||
|
|
d98dda4676 | ||
|
|
d69ddc1edc | ||
|
|
9bf41969f0 | ||
|
|
0f23b7bff5 | ||
|
|
4e57d2094e | ||
|
|
7f9efd53df | ||
|
|
da70c3222e | ||
|
|
9d902d63ce | ||
|
|
f4f0a4a471 | ||
|
|
3323c1d319 | ||
|
|
f20dc6b698 | ||
|
|
4b2ac1f369 | ||
|
|
8daf47fb3a | ||
|
|
6c980579cd | ||
|
|
5c73c4e2ee | ||
|
|
5daf59cc66 | ||
|
|
0ade9205cc | ||
|
|
06edabdde1 | ||
|
|
8b4e5a82a8 | ||
|
|
3445223311 | ||
|
|
fa6c0127e6 | ||
|
|
97323d1c68 | ||
|
|
458dd1b9d9 | ||
|
|
9d02d1d767 | ||
|
|
1a636fb47a | ||
|
|
0759fface9 | ||
|
|
325b72bc31 | ||
|
|
f01a9a7859 | ||
|
|
9aefd2dfee | ||
|
|
d07e4a1dd3 | ||
|
|
8a257ec00a | ||
|
|
2f4de1acf7 | ||
|
|
ec95c45f70 | ||
|
|
3a88f7eb20 | ||
|
|
0d5da826d4 | ||
|
|
9b795698b8 | ||
|
|
041fb77639 | ||
|
|
8224cce583 | ||
|
|
d18dcd7775 | ||
|
|
5f5ef20131 | ||
|
|
f0a07a353b | ||
|
|
948de6bbd2 | ||
|
|
598b74d42c | ||
|
|
935a48ed1a | ||
|
|
de39e24bf7 | ||
|
|
519b11eba1 | ||
|
|
379fd64fa8 | ||
|
|
59c019a6fb | ||
|
|
fad3bcccb2 | ||
|
|
bd6697ad95 | ||
|
|
f8dc7c9f54 | ||
|
|
4a3741129d | ||
|
|
77ba9404ac | ||
|
|
0aaf6119ec | ||
|
|
f08427c138 | ||
|
|
2dbb000908 | ||
|
|
c980e19995 | ||
|
|
6162374ca9 | ||
|
|
44bdd9a2ef | ||
|
|
db493d6e5e | ||
|
|
75695f16a5 | ||
|
|
a0407d07fa | ||
|
|
9ec733e527 | ||
|
|
5ef04dab52 | ||
|
|
aea316f1e9 | ||
|
|
235ba3df5c | ||
|
|
099a0f18ef | ||
|
|
fff696ee31 | ||
|
|
2e3ce6eab3 | ||
|
|
9e2003f88a | ||
|
|
42e1d49fbe | ||
|
|
814630ca60 | ||
|
|
87cf187774 | ||
|
|
6ddd8862cd | ||
|
|
f1373193dc | ||
|
|
8a4b77f9da | ||
|
|
5f53fe7884 | ||
|
|
7ab4ca0e7f | ||
|
|
e36f389e82 | ||
|
|
c61023f554 | ||
|
|
d25535c3f3 | ||
|
|
c323161f24 | ||
|
|
255579aaa7 | ||
|
|
f7102ba826 | ||
|
|
cefabd79a8 | ||
|
|
df70249520 | ||
|
|
77eb2ca619 | ||
|
|
ee25219edd | ||
|
|
b1fccabb34 | ||
|
|
a6355329bf | ||
|
|
0398b24b42 | ||
|
|
75b1dddf91 | ||
|
|
e1e80ffc3e | ||
|
|
71896485fd | ||
|
|
ef00199fb4 | ||
|
|
8f4a008139 | ||
|
|
d8cc798c2b | ||
|
|
6582f6da5c | ||
|
|
0334ffa625 | ||
|
|
d11fbd2c60 | ||
|
|
6a7c3f188e | ||
|
|
427e2c962a | ||
|
|
27db7f806f | ||
|
|
3590fbfa76 | ||
|
|
cd0094f772 | ||
|
|
06bc8e6712 | ||
|
|
fc5f9bb448 | ||
|
|
a0740f7ef7 | ||
|
|
a0923cbdd0 | ||
|
|
f92e362b2e | ||
|
|
aa23d8ecd2 | ||
|
|
7b62c41060 | ||
|
|
26acab64b7 | ||
|
|
e0f03790b1 | ||
|
|
3ab842b0f5 | ||
|
|
b8e8ef8929 | ||
|
|
465d124183 | ||
|
|
d310e56fa3 | ||
|
|
a1ca428c90 | ||
|
|
16750865d1 | ||
|
|
f3b476c592 | ||
|
|
5267d31d56 | ||
|
|
b44f56319f | ||
|
|
0209c268bb | ||
|
|
912d984346 | ||
|
|
aae6ecbaff | ||
|
|
64737330a4 | ||
|
|
2eda97f1c3 | ||
|
|
66831dcf70 | ||
|
|
1044b0419a | ||
|
|
771d9280ec | ||
|
|
862bc0a3bf | ||
|
|
c01608b6a1 | ||
|
|
199c41e16e | ||
|
|
3b3bf6c217 | ||
|
|
f52c21f457 | ||
|
|
b5d0f72f16 | ||
|
|
148a1be0a3 | ||
|
|
d6dd430abd | ||
|
|
ae78112c50 | ||
|
|
01cf7445f3 | ||
|
|
31085d5e53 | ||
|
|
c42e9d244f | ||
|
|
e98b5e8b4e | ||
|
|
68e00c7c36 | ||
|
|
4f138a1749 | ||
|
|
03bf241c33 | ||
|
|
a887406c24 | ||
|
|
d51e95ba7e | ||
|
|
3d01f2aa34 | ||
|
|
634c416645 | ||
|
|
57de86cc61 | ||
|
|
12719b6e87 | ||
|
|
a077d996e3 | ||
|
|
c23d5095de | ||
|
|
7601f0e93e | ||
|
|
aad3f03890 | ||
|
|
55d0b6e8b9 | ||
|
|
38eac40d56 | ||
|
|
80f3f1bc25 | ||
|
|
b1a0db547b | ||
|
|
75d7b5f926 | ||
|
|
349d814814 | ||
|
|
c8743031e0 | ||
|
|
4adb9cf4bb | ||
|
|
74f475e735 | ||
|
|
875cecba74 | ||
|
|
7d411a4686 | ||
|
|
02a2401596 | ||
|
|
e4b488a7b5 | ||
|
|
98079ddd79 | ||
|
|
d70942f47b | ||
|
|
58e4701557 | ||
|
|
dbf47ee55a | ||
|
|
af7ea6e96e | ||
|
|
8f1e0140e7 | ||
|
|
35c3c9e3c2 | ||
|
|
d06acbcb19 | ||
|
|
9667c2282f | ||
|
|
a937a68317 | ||
|
|
2185112d84 | ||
|
|
91926601dc | ||
|
|
361d6c16c2 | ||
|
|
7e2496e88e | ||
|
|
5b84e29882 | ||
|
|
7cc2a653f2 | ||
|
|
2584940016 | ||
|
|
c6d4c0c7f2 | ||
|
|
1ef4241727 | ||
|
|
68fafd3002 | ||
|
|
2b2cda7a2b | ||
|
|
3cfe9fe146 | ||
|
|
a23b559b4c | ||
|
|
33ee7168ba | ||
|
|
34d0c55ea5 | ||
|
|
53a5a9e9ae | ||
|
|
e30e08a7d6 | ||
|
|
12e2b3514a | ||
|
|
626af2d809 | ||
|
|
76912c062a | ||
|
|
6c3faafed2 | ||
|
|
e51dead636 | ||
|
|
d087e46bd1 | ||
|
|
37f6f3af24 | ||
|
|
e1bdc23dd2 | ||
|
|
2e78653ff9 | ||
|
|
f5f74e12c1 | ||
|
|
18fdcc94e5 | ||
|
|
7ad036992f | ||
|
|
172b5924af | ||
|
|
8852220f59 | ||
|
|
7325791599 | ||
|
|
522c11a763 | ||
|
|
0fadeffaee | ||
|
|
49a9c9ba6a | ||
|
|
1c094038bc | ||
|
|
a013693f80 | ||
|
|
f6a016f49d | ||
|
|
45c4739374 | ||
|
|
2dd029de12 | ||
|
|
903b1fc97f | ||
|
|
89eb795293 | ||
|
|
7e3ea813c1 | ||
|
|
7b95087b9d | ||
|
|
971d62595a | ||
|
|
ffbe8e076d | ||
|
|
2c639431b1 | ||
|
|
aacd1cb394 | ||
|
|
e3731fb160 | ||
|
|
8dbc9e7b68 | ||
|
|
abe67acf8a | ||
|
|
4ff8a691bc | ||
|
|
1b308e1d2a | ||
|
|
bd6c1d6b49 | ||
|
|
3af5d3b738 | ||
|
|
7730895158 | ||
|
|
de9ecfd01c | ||
|
|
95fdd8d619 | ||
|
|
9f7822851c | ||
|
|
9b2035d194 | ||
|
|
93d45d7a04 | ||
|
|
709f842457 | ||
|
|
2dfb74410d | ||
|
|
1eb5e75972 | ||
|
|
3475d915cb | ||
|
|
48e78e9be1 | ||
|
|
a838421ea3 | ||
|
|
1c4e85b4df | ||
|
|
dac4f17fea | ||
|
|
56b8fb024c | ||
|
|
b95693056c | ||
|
|
c34fc64688 | ||
|
|
7cf6f18c1f | ||
|
|
bbbb6b2a01 | ||
|
|
76f88caf43 | ||
|
|
2bccf8c624 | ||
|
|
0c5e5f6630 | ||
|
|
d475d1f081 | ||
|
|
d2f334c1f7 | ||
|
|
603ceefaa6 | ||
|
|
e082d60a24 | ||
|
|
5dae738067 | ||
|
|
0c78723174 | ||
|
|
5a41d69b2a | ||
|
|
c146a138e3 | ||
|
|
31b8c6a214 | ||
|
|
9191dfaf05 | ||
|
|
1108d8b34e | ||
|
|
7837a5bc7e | ||
|
|
0a844f8e96 | ||
|
|
a03223b86f | ||
|
|
0cf7794b16 | ||
|
|
854d40edc5 | ||
|
|
84a2cedf18 | ||
|
|
3f30836734 | ||
|
|
cc9555aff0 | ||
|
|
20aee96706 | ||
|
|
18b5958d46 | ||
|
|
5317202c38 | ||
|
|
d771043e88 | ||
|
|
f8f1071818 | ||
|
|
d3e0a0dee4 | ||
|
|
554172759c | ||
|
|
5b6a8e6001 | ||
|
|
467bbc0dd5 | ||
|
|
6d9f9323c5 | ||
|
|
0c2489605d | ||
|
|
8b1b89a984 | ||
|
|
47e272c35a | ||
|
|
417a81fda3 | ||
|
|
dba62ff3a5 | ||
|
|
d70e935526 | ||
|
|
5c1063df7f | ||
|
|
cb485b2019 | ||
|
|
b2af50960f | ||
|
|
eac5b8bfbd | ||
|
|
604e43b28d | ||
|
|
53985b3c4d | ||
|
|
b6e02cbbd2 | ||
|
|
91935631ac | ||
|
|
8de30b568a | ||
|
|
485da9fd35 | ||
|
|
0796d79d19 | ||
|
|
92981ae3f2 | ||
|
|
8ed1adf3db | ||
|
|
440a3823a6 | ||
|
|
718961de68 | ||
|
|
330f62a7fa | ||
|
|
584e2d646f | ||
|
|
1fd4cb87b2 | ||
|
|
4aba2e8b72 | ||
|
|
2f36d769aa | ||
|
|
399eacf486 | ||
|
|
231cc878cb | ||
|
|
aa676b313f | ||
|
|
dd0ed0ef17 | ||
|
|
d5649821ae | ||
|
|
4cea757e70 | ||
|
|
a751bc159c | ||
|
|
5d31242fbf | ||
|
|
d7fd72193f | ||
|
|
72ff5b9d8c | ||
|
|
ce29f695b4 | ||
|
|
12b174b10e | ||
|
|
333203d871 | ||
|
|
c114987523 | ||
|
|
b48083f33f | ||
|
|
482bec824f | ||
|
|
684a9a8c5a | ||
|
|
54a76d3773 | ||
|
|
8a75d8b015 | ||
|
|
f206357412 | ||
|
|
8224cd9063 | ||
|
|
6286d9a3a5 | ||
|
|
3a9e8e9fd4 | ||
|
|
cb1cb06478 | ||
|
|
2d5e066c8c | ||
|
|
15968714bd | ||
|
|
8bf38552de | ||
|
|
b13fbad0fe | ||
|
|
f560bd077f | ||
|
|
4372d0bfef | ||
|
|
31361c4d3c | ||
|
|
59241c5bee | ||
|
|
2a9b61f099 | ||
|
|
6df4208836 | ||
|
|
9d615cdaa0 | ||
|
|
6a818b8a09 | ||
|
|
2aaf29acb5 | ||
|
|
a42f826acb | ||
|
|
e10a3533a5 | ||
|
|
91ec3ddbeb | ||
|
|
755ac3b069 | ||
|
|
60b8973559 | ||
|
|
d2ef679d42 | ||
|
|
d4e0da0890 | ||
|
|
565b802a6b | ||
|
|
6c79e6c09a | ||
|
|
780762f9d2 | ||
|
|
30fcc71983 | ||
|
|
3501a4bdf9 | ||
|
|
73a0cafc1e | ||
|
|
e309c80474 | ||
|
|
544b6739dd | ||
|
|
a4a53692f8 | ||
|
|
c4ba257c64 | ||
|
|
342e58ce4f | ||
|
|
47b2585cfd | ||
|
|
4111db013f | ||
|
|
536c987c39 | ||
|
|
a534d4e9e1 | ||
|
|
74586aa9df | ||
|
|
8c74f5ddfd | ||
|
|
80d34260ea | ||
|
|
bddfa2100f | ||
|
|
1ca608bcd1 | ||
|
|
6aa7283076 | ||
|
|
f89fc1cadd | ||
|
|
97e05d2a6b | ||
|
|
8bbc7395db | ||
|
|
408c2f99d0 | ||
|
|
809b9c68fa | ||
|
|
ba8c035846 | ||
|
|
27f1fde413 | ||
|
|
220e133fca | ||
|
|
d3b4b9970a | ||
|
|
a4770107a6 | ||
|
|
ef549d513c | ||
|
|
d2158ca6f4 | ||
|
|
ce3eb0a315 | ||
|
|
60829f7ec6 | ||
|
|
9a50fd584c | ||
|
|
392a270261 | ||
|
|
3bee3af6ed | ||
|
|
83537993d7 | ||
|
|
7dd4862a89 | ||
|
|
db973c8fc2 | ||
|
|
afaf7ce8c3 | ||
|
|
26465fb85f | ||
|
|
88236bc05f | ||
|
|
76eb7d0fff | ||
|
|
f67a6df110 | ||
|
|
75e75d9afe | ||
|
|
ed78e127d0 | ||
|
|
d432ade714 | ||
|
|
06b3422d5f | ||
|
|
cbe1cf06c4 | ||
|
|
0a2d92081b | ||
|
|
c88647104d | ||
|
|
05aff4a4f1 | ||
|
|
0d140bd1af | ||
|
|
93e45f0f0d | ||
|
|
a342160803 | ||
|
|
f6c29409dc | ||
|
|
7d25b9e194 | ||
|
|
36d64fb531 | ||
|
|
d828517e78 | ||
|
|
14977a9350 | ||
|
|
29f63f37c8 | ||
|
|
3d99d9779a | ||
|
|
6d02a43a75 | ||
|
|
5483497d7a | ||
|
|
934dd9e196 | ||
|
|
1188f408dd | ||
|
|
15c7d30d9a | ||
|
|
9862317174 | ||
|
|
ec9eb28f4c | ||
|
|
1bdd816910 | ||
|
|
5d347f6d6f | ||
|
|
b97eb2b858 | ||
|
|
ad6f6a1d29 | ||
|
|
6723a40be6 |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -15,8 +15,12 @@ ml/backend/**/*.cu linguist-vendored
|
||||
ml/backend/**/*.cuh linguist-vendored
|
||||
ml/backend/**/*.m linguist-vendored
|
||||
ml/backend/**/*.metal linguist-vendored
|
||||
ml/backend/**/*.comp linguist-vendored
|
||||
ml/backend/**/*.glsl linguist-vendored
|
||||
ml/backend/**/CMakeLists.txt linguist-vendored
|
||||
|
||||
app/webview linguist-vendored
|
||||
|
||||
llama/build-info.cpp linguist-generated
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.s linguist-generated
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/10_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/10_bug_report.yml
vendored
@@ -13,7 +13,7 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. See [Troubleshooting Guide](https://github.com/ollama/ollama/blob/main/docs/troubleshooting.md#how-to-troubleshoot-issues) for details.
|
||||
description: Please copy and paste any relevant log output. See [Troubleshooting Guide](https://github.com/ollama/ollama/blob/main/docs/troubleshooting.mdx#how-to-troubleshoot-issues) for details.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
99
.github/workflows/release.yaml
vendored
99
.github/workflows/release.yaml
vendored
@@ -16,13 +16,15 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
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\"'" >>$GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" >>$GITHUB_OUTPUT
|
||||
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
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-14-xlarge
|
||||
@@ -53,6 +55,9 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
- run: |
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
@@ -63,6 +68,7 @@ jobs:
|
||||
name: bundles-darwin
|
||||
path: |
|
||||
dist/*.tgz
|
||||
dist/*.tar.zst
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
|
||||
@@ -104,6 +110,13 @@ jobs:
|
||||
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'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 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'
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
@@ -113,13 +126,14 @@ jobs:
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ')
|
||||
- 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 }}
|
||||
@@ -149,6 +163,18 @@ jobs:
|
||||
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 ${{ 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 "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
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
|
||||
@@ -159,19 +185,20 @@ jobs:
|
||||
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 }}
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build target "${{ matrix.preset }}"
|
||||
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, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || 'CPU' }}" --strip
|
||||
cmake --install build --component "${{ 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
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
@@ -228,6 +255,9 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -264,23 +294,26 @@ jobs:
|
||||
KEY_CONTAINER: ${{ vars.KEY_CONTAINER }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# - uses: google-github-actions/auth@v2
|
||||
# with:
|
||||
# project_id: ollama
|
||||
# credentials_json: ${{ secrets.GOOGLE_SIGNING_CREDENTIALS }}
|
||||
# - run: |
|
||||
# $ErrorActionPreference = "Stop"
|
||||
# Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=323507" -OutFile "${{ runner.temp }}\sdksetup.exe"
|
||||
# Start-Process "${{ runner.temp }}\sdksetup.exe" -ArgumentList @("/q") -NoNewWindow -Wait
|
||||
- uses: google-github-actions/auth@v2
|
||||
with:
|
||||
project_id: ollama
|
||||
credentials_json: ${{ secrets.GOOGLE_SIGNING_CREDENTIALS }}
|
||||
- run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=323507" -OutFile "${{ runner.temp }}\sdksetup.exe"
|
||||
Start-Process "${{ runner.temp }}\sdksetup.exe" -ArgumentList @("/q") -NoNewWindow -Wait
|
||||
|
||||
# Invoke-WebRequest -Uri "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/cng-v1.0/kmscng-1.0-windows-amd64.zip" -OutFile "${{ runner.temp }}\plugin.zip"
|
||||
# Expand-Archive -Path "${{ runner.temp }}\plugin.zip" -DestinationPath "${{ runner.temp }}\plugin\"
|
||||
# & "${{ runner.temp }}\plugin\*\kmscng.msi" /quiet
|
||||
Invoke-WebRequest -Uri "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/cng-v1.0/kmscng-1.0-windows-amd64.zip" -OutFile "${{ runner.temp }}\plugin.zip"
|
||||
Expand-Archive -Path "${{ runner.temp }}\plugin.zip" -DestinationPath "${{ runner.temp }}\plugin\"
|
||||
& "${{ runner.temp }}\plugin\*\kmscng.msi" /quiet
|
||||
|
||||
# echo "${{ vars.OLLAMA_CERT }}" >ollama_inc.crt
|
||||
echo "${{ vars.OLLAMA_CERT }}" >ollama_inc.crt
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -304,6 +337,7 @@ jobs:
|
||||
name: bundles-windows
|
||||
path: |
|
||||
dist/*.zip
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
linux-build:
|
||||
@@ -312,13 +346,13 @@ jobs:
|
||||
include:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: archive_novulkan
|
||||
target: archive
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: rocm
|
||||
- os: linux
|
||||
arch: arm64
|
||||
target: archive_novulkan
|
||||
target: archive
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
@@ -339,12 +373,17 @@ jobs:
|
||||
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 ;;
|
||||
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 ;;
|
||||
@@ -359,13 +398,13 @@ jobs:
|
||||
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 | pigz -9vc >$(basename ${ARCHIVE//.*/}.tgz);
|
||||
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: |
|
||||
*.tgz
|
||||
*.tar.zst
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
docker-build-push:
|
||||
@@ -374,14 +413,12 @@ jobs:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
target: novulkan
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: novulkan
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
@@ -394,14 +431,6 @@ jobs:
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-vulkan'
|
||||
target: default
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
@@ -419,7 +448,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.preset }}
|
||||
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
|
||||
@@ -487,6 +515,9 @@ jobs:
|
||||
- name: Log dist contents
|
||||
run: |
|
||||
ls -l dist/
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
@@ -509,7 +540,7 @@ jobs:
|
||||
- name: Upload release artifacts
|
||||
run: |
|
||||
pids=()
|
||||
for payload in dist/*.txt dist/*.zip dist/*.tgz dist/*.exe dist/*.dmg ; do
|
||||
for payload in dist/*.txt dist/*.zip dist/*.tgz dist/*.tar.zst dist/*.exe dist/*.dmg dist/*.ps1 dist/*.sh ; do
|
||||
echo "Uploading $payload"
|
||||
gh release upload ${GITHUB_REF_NAME} $payload --clobber &
|
||||
pids[$!]=$!
|
||||
|
||||
22
.github/workflows/test-install.yaml
vendored
Normal file
22
.github/workflows/test-install.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: test-install
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/install.sh'
|
||||
- '.github/workflows/test-install.yaml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run install script
|
||||
run: sh ./scripts/install.sh
|
||||
env:
|
||||
OLLAMA_NO_START: 1 # do not start app
|
||||
- name: Verify ollama is available
|
||||
run: ollama --version
|
||||
96
.github/workflows/test.yaml
vendored
96
.github/workflows/test.yaml
vendored
@@ -22,6 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -37,6 +38,7 @@ jobs:
|
||||
}
|
||||
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -83,7 +85,7 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}
|
||||
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
|
||||
@@ -172,12 +174,13 @@ jobs:
|
||||
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-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- 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'
|
||||
@@ -200,82 +203,37 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
GOEXPERIMENT: 'synctest'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
|
||||
|
||||
- name: cache restore
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# NOTE: The -3- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# The caching strategy of setup-go is less than ideal, and wastes
|
||||
# time by not saving artifacts due to small failures like the linter
|
||||
# complaining, etc. This means subsequent have to rebuild their world
|
||||
# again until all checks pass. For instance, if you mispell a word,
|
||||
# you're punished until you fix it. This is more hostile than
|
||||
# helpful.
|
||||
cache: false
|
||||
|
||||
go-version-file: go.mod
|
||||
|
||||
# It is tempting to run this in a platform independent way, but the past
|
||||
# shows this codebase will see introductions of platform specific code
|
||||
# generation, and so we need to check this per platform to ensure we
|
||||
# don't abuse go generate on specific platforms.
|
||||
- name: check that 'go generate' is clean
|
||||
if: always()
|
||||
node-version: '20'
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./app/ui/app
|
||||
run: npm ci
|
||||
- name: Install tscriptify
|
||||
run: |
|
||||
go generate ./...
|
||||
git diff --name-only --exit-code || (echo "Please run 'go generate ./...'." && exit 1)
|
||||
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
|
||||
- name: Run UI tests
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: go test
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
|
||||
# TODO(bmizerany): replace this heavy tool with just the
|
||||
# tools/checks/binaries we want and then make them all run in parallel
|
||||
# across jobs, not on a single tiny vm on Github Actions.
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
args: --timeout 10m0s -v
|
||||
|
||||
- name: cache save
|
||||
# Always save the cache, even if the job fails. The artifacts produced
|
||||
# during the building of test binaries are not all for naught. They can
|
||||
# be used to speed up subsequent runs.
|
||||
if: always()
|
||||
|
||||
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# NOTE: The -3- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -284,4 +242,4 @@ jobs:
|
||||
- name: Verify patches apply cleanly and do not change files
|
||||
run: |
|
||||
make -f Makefile.sync clean checkout apply-patches sync
|
||||
git diff --compact-summary --exit-code
|
||||
git diff --compact-summary --exit-code
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
@@ -7,35 +6,46 @@ linters:
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- gocheckcompilerdirectives
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- intrange
|
||||
- makezero
|
||||
- misspell
|
||||
- nilerr
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- usetesting
|
||||
- wastedassign
|
||||
- whitespace
|
||||
disable:
|
||||
- usestdlibvars
|
||||
- errcheck
|
||||
linters-settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -SA1019 # omit Deprecated check
|
||||
- usestdlibvars
|
||||
settings:
|
||||
govet:
|
||||
disable:
|
||||
- unusedresult
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -QF* # disable quick fix suggestions
|
||||
- -SA1019
|
||||
- -ST1000 # package comment format
|
||||
- -ST1003 # underscores in package names
|
||||
- -ST1005 # error strings should not be capitalized
|
||||
- -ST1012 # error var naming (ErrFoo)
|
||||
- -ST1016 # receiver name consistency
|
||||
- -ST1020 # comment on exported function format
|
||||
- -ST1021 # comment on exported type format
|
||||
- -ST1022 # comment on exported var format
|
||||
- -ST1023 # omit type from declaration
|
||||
severity:
|
||||
default-severity: error
|
||||
default: error
|
||||
rules:
|
||||
- linters:
|
||||
- gofmt
|
||||
- goimports
|
||||
- intrange
|
||||
severity: info
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- gofumpt
|
||||
|
||||
@@ -2,6 +2,22 @@ cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
# Handle cross-compilation on macOS: when CMAKE_OSX_ARCHITECTURES is set to a
|
||||
# single architecture different from the host, override CMAKE_SYSTEM_PROCESSOR
|
||||
# to match. This is necessary because CMAKE_SYSTEM_PROCESSOR defaults to the
|
||||
# host architecture, but downstream projects (like MLX) use it to detect the
|
||||
# target architecture.
|
||||
if(CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES ";")
|
||||
# Single architecture specified
|
||||
if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64" AND NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "x86_64")
|
||||
message(STATUS "Cross-compiling for x86_64: overriding CMAKE_SYSTEM_PROCESSOR from ${CMAKE_SYSTEM_PROCESSOR} to x86_64")
|
||||
set(CMAKE_SYSTEM_PROCESSOR "x86_64")
|
||||
elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64" AND NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
message(STATUS "Cross-compiling for arm64: overriding CMAKE_SYSTEM_PROCESSOR from ${CMAKE_SYSTEM_PROCESSOR} to arm64")
|
||||
set(CMAKE_SYSTEM_PROCESSOR "arm64")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
include(CheckLanguage)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
@@ -12,7 +28,7 @@ set(BUILD_SHARED_LIBS ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
@@ -32,9 +48,10 @@ if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
|
||||
if (CMAKE_OSX_ARCHITECTURES MATCHES "x86_64")
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
@@ -54,6 +71,13 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cp
|
||||
|
||||
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)
|
||||
@@ -140,14 +164,56 @@ if(CMAKE_HIP_COMPILER)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
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
|
||||
)
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
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)
|
||||
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart nvrtc nvrtc-builtins cudnn nccl openblas gfortran
|
||||
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()
|
||||
|
||||
# Manually install cudart and cublas since they might not be picked up as direct dependencies
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB CUDART_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*")
|
||||
if(CUDART_LIBS)
|
||||
install(FILES ${CUDART_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"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 2",
|
||||
"CMAKE_CUDA_FLAGS": "-t 4",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
@@ -83,6 +83,28 @@
|
||||
"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": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
@@ -140,6 +162,21 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ See the [development documentation](./docs/development.md) for instructions on h
|
||||
|
||||
* New features: new features (e.g. API fields, environment variables) add surface area to Ollama and make it harder to maintain in the long run as they cannot be removed without potentially breaking users in the future.
|
||||
* Refactoring: large code improvements are important, but can be harder or take longer to review and merge.
|
||||
* Documentation: small updates to fill in or correct missing documentation is helpful, however large documentation additions can be hard to maintain over time.
|
||||
* Documentation: small updates to fill in or correct missing documentation are helpful, however large documentation additions can be hard to maintain over time.
|
||||
|
||||
### Issues that may not be accepted
|
||||
|
||||
@@ -43,7 +43,7 @@ Tips for proposals:
|
||||
* Explain how the change will be tested.
|
||||
|
||||
Additionally, for bonus points: Provide draft documentation you would expect to
|
||||
see if the change were accepted.
|
||||
see if the changes were accepted.
|
||||
|
||||
## Pull requests
|
||||
|
||||
@@ -66,7 +66,6 @@ Examples:
|
||||
|
||||
llm/backend/mlx: support the llama architecture
|
||||
CONTRIBUTING: provide clarity on good commit messages, and bad
|
||||
docs: simplify manual installation with shorter curl commands
|
||||
|
||||
Bad Examples:
|
||||
|
||||
|
||||
87
Dockerfile
87
Dockerfile
@@ -9,15 +9,10 @@ ARG JETPACK6VERSION=r36.4.0
|
||||
ARG CMAKEVERSION=3.31.2
|
||||
ARG VULKANVERSION=1.4.321.1
|
||||
|
||||
# We require gcc v10 minimum. v10.3 has regressions, so the rockylinux 8.5 AppStream has the latest compatible version
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN yum install -y yum-utils \
|
||||
&& yum-config-manager --add-repo https://dl.rockylinux.org/vault/rocky/8.5/AppStream/\$basearch/os/ \
|
||||
&& rpm --import https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-8 \
|
||||
&& dnf install -y yum-utils ccache gcc-toolset-10-gcc-10.2.1-8.2.el8 gcc-toolset-10-gcc-c++-10.2.1-8.2.el8 gcc-toolset-10-binutils-2.35-11.el8 \
|
||||
&& dnf install -y ccache \
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-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-10/root/usr/bin:$PATH
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
ARG VULKANVERSION
|
||||
RUN wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz \
|
||||
@@ -32,21 +27,21 @@ ENV PATH=/${VULKANVERSION}/x86_64/bin:$PATH
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
RUN yum install -y yum-utils epel-release \
|
||||
&& dnf install -y clang ccache \
|
||||
&& dnf install -y clang ccache git \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/sbsa/cuda-rhel8.repo
|
||||
ENV CC=clang CXX=clang++
|
||||
|
||||
FROM base-${TARGETARCH} AS base
|
||||
ARG CMAKEVERSION
|
||||
RUN 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
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
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
|
||||
ARG PARALLEL
|
||||
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 --parallel ${PARALLEL} --preset 'CPU' \
|
||||
@@ -57,6 +52,8 @@ ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
ARG PARALLEL
|
||||
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 --parallel ${PARALLEL} --preset 'CUDA 11' \
|
||||
@@ -67,6 +64,8 @@ ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
ARG PARALLEL
|
||||
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 --parallel ${PARALLEL} --preset 'CUDA 12' \
|
||||
@@ -78,6 +77,8 @@ ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
ARG PARALLEL
|
||||
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 --parallel ${PARALLEL} --preset 'CUDA 13' \
|
||||
@@ -87,6 +88,8 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
FROM base AS rocm-6
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
ARG PARALLEL
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 6' \
|
||||
&& cmake --build --parallel ${PARALLEL} --preset 'ROCm 6' \
|
||||
@@ -118,11 +121,37 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
||||
|
||||
FROM base AS vulkan
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --parallel --preset 'Vulkan' \
|
||||
&& cmake --install build --component Vulkan --strip --parallel 8
|
||||
&& cmake --install build --component Vulkan --strip --parallel 8
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
&& dnf install -y libnccl libnccl-devel
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
ENV BLAS_INCLUDE_DIRS=/usr/include/openblas
|
||||
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"
|
||||
ARG PARALLEL
|
||||
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 go.mod go.sum .
|
||||
COPY MLX_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
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
|
||||
&& cmake --build --parallel ${PARALLEL} --preset 'MLX CUDA 13' \
|
||||
&& cmake --install build --component MLX --strip --parallel ${PARALLEL}
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -131,18 +160,23 @@ RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Clone mlx-c headers for CGO (version from MLX_VERSION file)
|
||||
RUN git clone --depth 1 --branch "$(cat MLX_VERSION)" https://github.com/ml-explore/mlx-c.git build/_deps/mlx-c-src
|
||||
ARG GOFLAGS="'-ldflags=-w -s'"
|
||||
ENV CGO_ENABLED=1
|
||||
ARG CGO_CFLAGS
|
||||
ARG CGO_CXXFLAGS
|
||||
ENV CGO_CFLAGS="${CGO_CFLAGS} -I/go/src/github.com/ollama/ollama/build/_deps/mlx-c-src"
|
||||
ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
go build -tags mlx -trimpath -buildmode=pie -o /bin/ollama .
|
||||
|
||||
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=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/
|
||||
@@ -159,34 +193,9 @@ ARG VULKANVERSION
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
# Temporary opt-out stages for Vulkan
|
||||
FROM --platform=linux/amd64 scratch AS amd64_novulkan
|
||||
# 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/
|
||||
FROM arm64 AS arm64_novulkan
|
||||
FROM ${FLAVOR}_novulkan AS archive_novulkan
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
FROM ubuntu:24.04 AS novulkan
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive_novulkan /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=archive_novulkan /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
|
||||
ENV OLLAMA_HOST=0.0.0.0:11434
|
||||
EXPOSE 11434
|
||||
ENTRYPOINT ["/bin/ollama"]
|
||||
CMD ["serve"]
|
||||
|
||||
FROM ubuntu:24.04 AS default
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive /bin /usr/bin
|
||||
|
||||
1
MLX_VERSION
Normal file
1
MLX_VERSION
Normal file
@@ -0,0 +1 @@
|
||||
v0.5.0
|
||||
@@ -1,6 +1,6 @@
|
||||
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
|
||||
WORKDIR=llama/vendor
|
||||
FETCH_HEAD=7049736b2dd9011bf819e298b844ebbc4b5afdc9
|
||||
FETCH_HEAD=ec98e2002
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@@ -57,7 +57,7 @@ checkout: $(WORKDIR)
|
||||
$(WORKDIR):
|
||||
git clone $(UPSTREAM) $(WORKDIR)
|
||||
|
||||
.PHONE: format-patches
|
||||
.PHONY: format-patches
|
||||
format-patches: llama/patches
|
||||
git -C $(WORKDIR) format-patch \
|
||||
--no-signature \
|
||||
@@ -66,7 +66,11 @@ format-patches: llama/patches
|
||||
-o $(realpath $<) \
|
||||
$(FETCH_HEAD)
|
||||
|
||||
.PHONE: clean
|
||||
.PHONY: clean
|
||||
clean: checkout
|
||||
@git -C $(WORKDIR) am --abort || true
|
||||
$(RM) llama/patches/.*.patched
|
||||
|
||||
.PHONY: print-base
|
||||
print-base:
|
||||
@echo $(FETCH_HEAD)
|
||||
850
README.md
850
README.md
@@ -1,20 +1,30 @@
|
||||
<div align="center">
|
||||
<a href="https://ollama.com">
|
||||
<img alt="ollama" width="240" src="https://github.com/ollama/ollama/assets/3325447/0d0b44e2-8f4a-4e99-9b52-a5c1c741c8f7">
|
||||
<p align="center">
|
||||
<a href="https://ollama.com">
|
||||
<img src="https://github.com/ollama/ollama/assets/3325447/0d0b44e2-8f4a-4e99-9b52-a5c1c741c8f7" alt="ollama" width="200"/>
|
||||
</a>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
# Ollama
|
||||
|
||||
Get up and running with large language models.
|
||||
Start building with open models.
|
||||
|
||||
## Download
|
||||
|
||||
### macOS
|
||||
|
||||
[Download](https://ollama.com/download/Ollama.dmg)
|
||||
```shell
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
|
||||
or [download manually](https://ollama.com/download/Ollama.dmg)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download](https://ollama.com/download/OllamaSetup.exe)
|
||||
```shell
|
||||
irm https://ollama.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
or [download manually](https://ollama.com/download/OllamaSetup.exe)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -22,7 +32,7 @@ Get up and running with large language models.
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
|
||||
[Manual install instructions](https://github.com/ollama/ollama/blob/main/docs/linux.md)
|
||||
[Manual install instructions](https://docs.ollama.com/linux#manual-install)
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -36,585 +46,311 @@ The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `olla
|
||||
### Community
|
||||
|
||||
- [Discord](https://discord.gg/ollama)
|
||||
- [𝕏 (Twitter)](https://x.com/ollama)
|
||||
- [Reddit](https://reddit.com/r/ollama)
|
||||
|
||||
## Quickstart
|
||||
## Get started
|
||||
|
||||
To run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
```
|
||||
ollama
|
||||
```
|
||||
|
||||
```shell
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `claude`, `codex`, `openclaw` and more.
|
||||
|
||||
### Coding
|
||||
|
||||
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).
|
||||
|
||||
### AI assistant
|
||||
|
||||
Use [OpenClaw](https://docs.ollama.com/integrations/openclaw) to turn Ollama into a personal AI assistant across WhatsApp, Telegram, Slack, Discord, and more:
|
||||
|
||||
```
|
||||
ollama launch openclaw
|
||||
```
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
|
||||
```
|
||||
ollama run gemma3
|
||||
```
|
||||
|
||||
## Model library
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
|
||||
Ollama supports a list of models available on [ollama.com/library](https://ollama.com/library 'ollama model library')
|
||||
|
||||
Here are some example models that can be downloaded:
|
||||
|
||||
| Model | Parameters | Size | Download |
|
||||
| ------------------ | ---------- | ----- | -------------------------------- |
|
||||
| Gemma 3 | 1B | 815MB | `ollama run gemma3:1b` |
|
||||
| Gemma 3 | 4B | 3.3GB | `ollama run gemma3` |
|
||||
| Gemma 3 | 12B | 8.1GB | `ollama run gemma3:12b` |
|
||||
| Gemma 3 | 27B | 17GB | `ollama run gemma3:27b` |
|
||||
| QwQ | 32B | 20GB | `ollama run qwq` |
|
||||
| DeepSeek-R1 | 7B | 4.7GB | `ollama run deepseek-r1` |
|
||||
| DeepSeek-R1 | 671B | 404GB | `ollama run deepseek-r1:671b` |
|
||||
| Llama 4 | 109B | 67GB | `ollama run llama4:scout` |
|
||||
| Llama 4 | 400B | 245GB | `ollama run llama4:maverick` |
|
||||
| Llama 3.3 | 70B | 43GB | `ollama run llama3.3` |
|
||||
| Llama 3.2 | 3B | 2.0GB | `ollama run llama3.2` |
|
||||
| Llama 3.2 | 1B | 1.3GB | `ollama run llama3.2:1b` |
|
||||
| Llama 3.2 Vision | 11B | 7.9GB | `ollama run llama3.2-vision` |
|
||||
| Llama 3.2 Vision | 90B | 55GB | `ollama run llama3.2-vision:90b` |
|
||||
| Llama 3.1 | 8B | 4.7GB | `ollama run llama3.1` |
|
||||
| Llama 3.1 | 405B | 231GB | `ollama run llama3.1:405b` |
|
||||
| Phi 4 | 14B | 9.1GB | `ollama run phi4` |
|
||||
| Phi 4 Mini | 3.8B | 2.5GB | `ollama run phi4-mini` |
|
||||
| Mistral | 7B | 4.1GB | `ollama run mistral` |
|
||||
| Moondream 2 | 1.4B | 829MB | `ollama run moondream` |
|
||||
| Neural Chat | 7B | 4.1GB | `ollama run neural-chat` |
|
||||
| Starling | 7B | 4.1GB | `ollama run starling-lm` |
|
||||
| Code Llama | 7B | 3.8GB | `ollama run codellama` |
|
||||
| Llama 2 Uncensored | 7B | 3.8GB | `ollama run llama2-uncensored` |
|
||||
| LLaVA | 7B | 4.5GB | `ollama run llava` |
|
||||
| Granite-3.3 | 8B | 4.9GB | `ollama run granite3.3` |
|
||||
|
||||
> [!NOTE]
|
||||
> You should have at least 8 GB of RAM available to run the 7B models, 16 GB to run the 13B models, and 32 GB to run the 33B models.
|
||||
|
||||
## Customize a model
|
||||
|
||||
### Import from GGUF
|
||||
|
||||
Ollama supports importing GGUF models in the Modelfile:
|
||||
|
||||
1. Create a file named `Modelfile`, with a `FROM` instruction with the local filepath to the model you want to import.
|
||||
|
||||
```
|
||||
FROM ./vicuna-33b.Q4_0.gguf
|
||||
```
|
||||
|
||||
2. Create the model in Ollama
|
||||
|
||||
```shell
|
||||
ollama create example -f Modelfile
|
||||
```
|
||||
|
||||
3. Run the model
|
||||
|
||||
```shell
|
||||
ollama run example
|
||||
```
|
||||
|
||||
### Import from Safetensors
|
||||
|
||||
See the [guide](docs/import.md) on importing models for more information.
|
||||
|
||||
### Customize a prompt
|
||||
|
||||
Models from the Ollama library can be customized with a prompt. For example, to customize the `llama3.2` model:
|
||||
|
||||
```shell
|
||||
ollama pull llama3.2
|
||||
```
|
||||
|
||||
Create a `Modelfile`:
|
||||
|
||||
```
|
||||
FROM llama3.2
|
||||
|
||||
# set the temperature to 1 [higher is more creative, lower is more coherent]
|
||||
PARAMETER temperature 1
|
||||
|
||||
# set the system message
|
||||
SYSTEM """
|
||||
You are Mario from Super Mario Bros. Answer as Mario, the assistant, only.
|
||||
"""
|
||||
```
|
||||
|
||||
Next, create and run the model:
|
||||
|
||||
```
|
||||
ollama create mario -f ./Modelfile
|
||||
ollama run mario
|
||||
>>> hi
|
||||
Hello! It's your friend Mario.
|
||||
```
|
||||
|
||||
For more information on working with a Modelfile, see the [Modelfile](docs/modelfile.md) documentation.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Create a model
|
||||
|
||||
`ollama create` is used to create a model from a Modelfile.
|
||||
|
||||
```shell
|
||||
ollama create mymodel -f ./Modelfile
|
||||
```
|
||||
|
||||
### Pull a model
|
||||
|
||||
```shell
|
||||
ollama pull llama3.2
|
||||
```
|
||||
|
||||
> This command can also be used to update a local model. Only the diff will be pulled.
|
||||
|
||||
### Remove a model
|
||||
|
||||
```shell
|
||||
ollama rm llama3.2
|
||||
```
|
||||
|
||||
### Copy a model
|
||||
|
||||
```shell
|
||||
ollama cp llama3.2 my-model
|
||||
```
|
||||
|
||||
### Multiline input
|
||||
|
||||
For multiline input, you can wrap text with `"""`:
|
||||
|
||||
```
|
||||
>>> """Hello,
|
||||
... world!
|
||||
... """
|
||||
I'm a basic program that prints the famous "Hello, world!" message to the console.
|
||||
```
|
||||
|
||||
### Multimodal models
|
||||
|
||||
```
|
||||
ollama run llava "What's in this image? /Users/jmorgan/Desktop/smile.png"
|
||||
```
|
||||
|
||||
> **Output**: The image features a yellow smiley face, which is likely the central focus of the picture.
|
||||
|
||||
### Pass the prompt as an argument
|
||||
|
||||
```shell
|
||||
ollama run llama3.2 "Summarize this file: $(cat README.md)"
|
||||
```
|
||||
|
||||
> **Output**: Ollama is a lightweight, extensible framework for building and running language models on the local machine. It provides a simple API for creating, running, and managing models, as well as a library of pre-built models that can be easily used in a variety of applications.
|
||||
|
||||
### Show model information
|
||||
|
||||
```shell
|
||||
ollama show llama3.2
|
||||
```
|
||||
|
||||
### List models on your computer
|
||||
|
||||
```shell
|
||||
ollama list
|
||||
```
|
||||
|
||||
### List which models are currently loaded
|
||||
|
||||
```shell
|
||||
ollama ps
|
||||
```
|
||||
|
||||
### Stop a model which is currently running
|
||||
|
||||
```shell
|
||||
ollama stop llama3.2
|
||||
```
|
||||
|
||||
### Start Ollama
|
||||
|
||||
`ollama serve` is used when you want to start ollama without running the desktop application.
|
||||
|
||||
## Building
|
||||
|
||||
See the [developer guide](https://github.com/ollama/ollama/blob/main/docs/development.md)
|
||||
|
||||
### Running local builds
|
||||
|
||||
Next, start the server:
|
||||
|
||||
```shell
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
Finally, in a separate shell, run a model:
|
||||
|
||||
```shell
|
||||
./ollama run llama3.2
|
||||
```
|
||||
See the [quickstart guide](https://docs.ollama.com/quickstart) for more details.
|
||||
|
||||
## REST API
|
||||
|
||||
Ollama has a REST API for running and managing models.
|
||||
|
||||
### Generate a response
|
||||
|
||||
```shell
|
||||
curl http://localhost:11434/api/generate -d '{
|
||||
"model": "llama3.2",
|
||||
"prompt":"Why is the sky blue?"
|
||||
}'
|
||||
```
|
||||
|
||||
### Chat with a model
|
||||
|
||||
```shell
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "llama3.2",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "why is the sky blue?" }
|
||||
]
|
||||
"model": "gemma3",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
See the [API documentation](./docs/api.md) for all endpoints.
|
||||
See the [API documentation](https://docs.ollama.com/api) for all endpoints.
|
||||
|
||||
### Python
|
||||
|
||||
```
|
||||
pip install ollama
|
||||
```
|
||||
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma3', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
},
|
||||
])
|
||||
print(response.message.content)
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```
|
||||
npm i ollama
|
||||
```
|
||||
|
||||
```javascript
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma3",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
```
|
||||
|
||||
## Supported backends
|
||||
|
||||
- [llama.cpp](https://github.com/ggml-org/llama.cpp) project founded by Georgi Gerganov.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLI reference](https://docs.ollama.com/cli)
|
||||
- [REST API reference](https://docs.ollama.com/api)
|
||||
- [Importing models](https://docs.ollama.com/import)
|
||||
- [Modelfile reference](https://docs.ollama.com/modelfile)
|
||||
- [Building from source](https://github.com/ollama/ollama/blob/main/docs/development.md)
|
||||
|
||||
## Community Integrations
|
||||
|
||||
### Web & Desktop
|
||||
> Want to add your project? Open a pull request.
|
||||
|
||||
- [Open WebUI](https://github.com/open-webui/open-webui)
|
||||
- [SwiftChat (macOS with ReactNative)](https://github.com/aws-samples/swift-chat)
|
||||
- [Enchanted (macOS native)](https://github.com/AugustDev/enchanted)
|
||||
- [Hollama](https://github.com/fmaclen/hollama)
|
||||
- [Lollms-Webui](https://github.com/ParisNeo/lollms-webui)
|
||||
- [LibreChat](https://github.com/danny-avila/LibreChat)
|
||||
- [Bionic GPT](https://github.com/bionic-gpt/bionic-gpt)
|
||||
- [HTML UI](https://github.com/rtcfirefly/ollama-ui)
|
||||
- [Saddle](https://github.com/jikkuatwork/saddle)
|
||||
- [TagSpaces](https://www.tagspaces.org) (A platform for file-based apps, [utilizing Ollama](https://docs.tagspaces.org/ai/) for the generation of tags and descriptions)
|
||||
- [Chatbot UI](https://github.com/ivanfioravanti/chatbot-ollama)
|
||||
- [Chatbot UI v2](https://github.com/mckaywrigley/chatbot-ui)
|
||||
- [Typescript UI](https://github.com/ollama-interface/Ollama-Gui?tab=readme-ov-file)
|
||||
- [Minimalistic React UI for Ollama Models](https://github.com/richawo/minimal-llm-ui)
|
||||
- [Ollamac](https://github.com/kevinhermawan/Ollamac)
|
||||
- [big-AGI](https://github.com/enricoros/big-AGI)
|
||||
- [Cheshire Cat assistant framework](https://github.com/cheshire-cat-ai/core)
|
||||
- [Amica](https://github.com/semperai/amica)
|
||||
- [chatd](https://github.com/BruceMacD/chatd)
|
||||
- [Ollama-SwiftUI](https://github.com/kghandour/Ollama-SwiftUI)
|
||||
- [Dify.AI](https://github.com/langgenius/dify)
|
||||
- [MindMac](https://mindmac.app)
|
||||
- [NextJS Web Interface for Ollama](https://github.com/jakobhoeg/nextjs-ollama-llm-ui)
|
||||
- [Msty](https://msty.app)
|
||||
- [Chatbox](https://github.com/Bin-Huang/Chatbox)
|
||||
- [WinForm Ollama Copilot](https://github.com/tgraupmann/WinForm_Ollama_Copilot)
|
||||
- [NextChat](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) with [Get Started Doc](https://docs.nextchat.dev/models/ollama)
|
||||
- [Alpaca WebUI](https://github.com/mmo80/alpaca-webui)
|
||||
- [OllamaGUI](https://github.com/enoch1118/ollamaGUI)
|
||||
- [OpenAOE](https://github.com/InternLM/OpenAOE)
|
||||
- [Odin Runes](https://github.com/leonid20000/OdinRunes)
|
||||
- [LLM-X](https://github.com/mrdjohnson/llm-x) (Progressive Web App)
|
||||
- [AnythingLLM (Docker + MacOs/Windows/Linux native app)](https://github.com/Mintplex-Labs/anything-llm)
|
||||
- [Ollama Basic Chat: Uses HyperDiv Reactive UI](https://github.com/rapidarchitect/ollama_basic_chat)
|
||||
- [Ollama-chats RPG](https://github.com/drazdra/ollama-chats)
|
||||
- [IntelliBar](https://intellibar.app/) (AI-powered assistant for macOS)
|
||||
- [Jirapt](https://github.com/AliAhmedNada/jirapt) (Jira Integration to generate issues, tasks, epics)
|
||||
- [ojira](https://github.com/AliAhmedNada/ojira) (Jira chrome plugin to easily generate descriptions for tasks)
|
||||
- [QA-Pilot](https://github.com/reid41/QA-Pilot) (Interactive chat tool that can leverage Ollama models for rapid understanding and navigation of GitHub code repositories)
|
||||
- [ChatOllama](https://github.com/sugarforever/chat-ollama) (Open Source Chatbot based on Ollama with Knowledge Bases)
|
||||
- [CRAG Ollama Chat](https://github.com/Nagi-ovo/CRAG-Ollama-Chat) (Simple Web Search with Corrective RAG)
|
||||
- [RAGFlow](https://github.com/infiniflow/ragflow) (Open-source Retrieval-Augmented Generation engine based on deep document understanding)
|
||||
- [StreamDeploy](https://github.com/StreamDeploy-DevRel/streamdeploy-llm-app-scaffold) (LLM Application Scaffold)
|
||||
- [chat](https://github.com/swuecho/chat) (chat web app for teams)
|
||||
- [Lobe Chat](https://github.com/lobehub/lobe-chat) with [Integrating Doc](https://lobehub.com/docs/self-hosting/examples/ollama)
|
||||
- [Ollama RAG Chatbot](https://github.com/datvodinh/rag-chatbot.git) (Local Chat with multiple PDFs using Ollama and RAG)
|
||||
- [BrainSoup](https://www.nurgo-software.com/products/brainsoup) (Flexible native client with RAG & multi-agent automation)
|
||||
- [macai](https://github.com/Renset/macai) (macOS client for Ollama, ChatGPT, and other compatible API back-ends)
|
||||
- [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) (RWKV offline LLM deployment tool, also usable as a client for ChatGPT and Ollama)
|
||||
- [Ollama Grid Search](https://github.com/dezoito/ollama-grid-search) (app to evaluate and compare models)
|
||||
- [Olpaka](https://github.com/Otacon/olpaka) (User-friendly Flutter Web App for Ollama)
|
||||
- [Casibase](https://casibase.org) (An open source AI knowledge base and dialogue system combining the latest RAG, SSO, ollama support, and multiple large language models.)
|
||||
- [OllamaSpring](https://github.com/CrazyNeil/OllamaSpring) (Ollama Client for macOS)
|
||||
- [LLocal.in](https://github.com/kartikm7/llocal) (Easy to use Electron Desktop Client for Ollama)
|
||||
- [Shinkai Desktop](https://github.com/dcSpark/shinkai-apps) (Two click install Local AI using Ollama + Files + RAG)
|
||||
- [AiLama](https://github.com/zeyoyt/ailama) (A Discord User App that allows you to interact with Ollama anywhere in Discord)
|
||||
- [Ollama with Google Mesop](https://github.com/rapidarchitect/ollama_mesop/) (Mesop Chat Client implementation with Ollama)
|
||||
- [R2R](https://github.com/SciPhi-AI/R2R) (Open-source RAG engine)
|
||||
- [Ollama-Kis](https://github.com/elearningshow/ollama-kis) (A simple easy-to-use GUI with sample custom LLM for Drivers Education)
|
||||
- [OpenGPA](https://opengpa.org) (Open-source offline-first Enterprise Agentic Application)
|
||||
- [Painting Droid](https://github.com/mateuszmigas/painting-droid) (Painting app with AI integrations)
|
||||
- [Kerlig AI](https://www.kerlig.com/) (AI writing assistant for macOS)
|
||||
- [AI Studio](https://github.com/MindWorkAI/AI-Studio)
|
||||
- [Sidellama](https://github.com/gyopak/sidellama) (browser-based LLM client)
|
||||
- [LLMStack](https://github.com/trypromptly/LLMStack) (No-code multi-agent framework to build LLM agents and workflows)
|
||||
- [BoltAI for Mac](https://boltai.com) (AI Chat Client for Mac)
|
||||
- [Harbor](https://github.com/av/harbor) (Containerized LLM Toolkit with Ollama as default backend)
|
||||
- [PyGPT](https://github.com/szczyglis-dev/py-gpt) (AI desktop assistant for Linux, Windows, and Mac)
|
||||
- [Alpaca](https://github.com/Jeffser/Alpaca) (An Ollama client application for Linux and macOS made with GTK4 and Adwaita)
|
||||
- [AutoGPT](https://github.com/Significant-Gravitas/AutoGPT/blob/master/docs/content/platform/ollama.md) (AutoGPT Ollama integration)
|
||||
- [Go-CREW](https://www.jonathanhecl.com/go-crew/) (Powerful Offline RAG in Golang)
|
||||
- [PartCAD](https://github.com/openvmp/partcad/) (CAD model generation with OpenSCAD and CadQuery)
|
||||
- [Ollama4j Web UI](https://github.com/ollama4j/ollama4j-web-ui) - Java-based Web UI for Ollama built with Vaadin, Spring Boot, and Ollama4j
|
||||
- [PyOllaMx](https://github.com/kspviswa/pyOllaMx) - macOS application capable of chatting with both Ollama and Apple MLX models.
|
||||
- [Cline](https://github.com/cline/cline) - Formerly known as Claude Dev is a VSCode extension for multi-file/whole-repo coding
|
||||
- [Cherry Studio](https://github.com/kangfenmao/cherry-studio) (Desktop client with Ollama support)
|
||||
- [ConfiChat](https://github.com/1runeberg/confichat) (Lightweight, standalone, multi-platform, and privacy-focused LLM chat interface with optional encryption)
|
||||
- [Archyve](https://github.com/nickthecook/archyve) (RAG-enabling document library)
|
||||
- [crewAI with Mesop](https://github.com/rapidarchitect/ollama-crew-mesop) (Mesop Web Interface to run crewAI with Ollama)
|
||||
- [Tkinter-based client](https://github.com/chyok/ollama-gui) (Python tkinter-based Client for Ollama)
|
||||
- [LLMChat](https://github.com/trendy-design/llmchat) (Privacy focused, 100% local, intuitive all-in-one chat interface)
|
||||
- [Local Multimodal AI Chat](https://github.com/Leon-Sander/Local-Multimodal-AI-Chat) (Ollama-based LLM Chat with support for multiple features, including PDF RAG, voice chat, image-based interactions, and integration with OpenAI.)
|
||||
- [ARGO](https://github.com/xark-argo/argo) (Locally download and run Ollama and Huggingface models with RAG and deep research on Mac/Windows/Linux)
|
||||
- [OrionChat](https://github.com/EliasPereirah/OrionChat) - OrionChat is a web interface for chatting with different AI providers
|
||||
- [G1](https://github.com/bklieger-groq/g1) (Prototype of using prompting strategies to improve the LLM's reasoning through o1-like reasoning chains.)
|
||||
- [Web management](https://github.com/lemonit-eric-mao/ollama-web-management) (Web management page)
|
||||
- [Promptery](https://github.com/promptery/promptery) (desktop client for Ollama.)
|
||||
- [Ollama App](https://github.com/JHubi1/ollama-app) (Modern and easy-to-use multi-platform client for Ollama)
|
||||
- [chat-ollama](https://github.com/annilq/chat-ollama) (a React Native client for Ollama)
|
||||
- [SpaceLlama](https://github.com/tcsenpai/spacellama) (Firefox and Chrome extension to quickly summarize web pages with ollama in a sidebar)
|
||||
- [YouLama](https://github.com/tcsenpai/youlama) (Webapp to quickly summarize any YouTube video, supporting Invidious as well)
|
||||
- [DualMind](https://github.com/tcsenpai/dualmind) (Experimental app allowing two models to talk to each other in the terminal or in a web interface)
|
||||
- [ollamarama-matrix](https://github.com/h1ddenpr0cess20/ollamarama-matrix) (Ollama chatbot for the Matrix chat protocol)
|
||||
- [ollama-chat-app](https://github.com/anan1213095357/ollama-chat-app) (Flutter-based chat app)
|
||||
- [Perfect Memory AI](https://www.perfectmemory.ai/) (Productivity AI assists personalized by what you have seen on your screen, heard, and said in the meetings)
|
||||
- [Hexabot](https://github.com/hexastack/hexabot) (A conversational AI builder)
|
||||
- [Reddit Rate](https://github.com/rapidarchitect/reddit_analyzer) (Search and Rate Reddit topics with a weighted summation)
|
||||
- [OpenTalkGpt](https://github.com/adarshM84/OpenTalkGpt) (Chrome Extension to manage open-source models supported by Ollama, create custom models, and chat with models from a user-friendly UI)
|
||||
- [VT](https://github.com/vinhnx/vt.ai) (A minimal multimodal AI chat app, with dynamic conversation routing. Supports local models via Ollama)
|
||||
- [Nosia](https://github.com/nosia-ai/nosia) (Easy to install and use RAG platform based on Ollama)
|
||||
- [Witsy](https://github.com/nbonamy/witsy) (An AI Desktop application available for Mac/Windows/Linux)
|
||||
- [Abbey](https://github.com/US-Artificial-Intelligence/abbey) (A configurable AI interface server with notebooks, document storage, and YouTube support)
|
||||
- [Minima](https://github.com/dmayboroda/minima) (RAG with on-premises or fully local workflow)
|
||||
- [aidful-ollama-model-delete](https://github.com/AidfulAI/aidful-ollama-model-delete) (User interface for simplified model cleanup)
|
||||
- [Perplexica](https://github.com/ItzCrazyKns/Perplexica) (An AI-powered search engine & an open-source alternative to Perplexity AI)
|
||||
- [Ollama Chat WebUI for Docker ](https://github.com/oslook/ollama-webui) (Support for local docker deployment, lightweight ollama webui)
|
||||
- [AI Toolkit for Visual Studio Code](https://aka.ms/ai-tooklit/ollama-docs) (Microsoft-official VSCode extension to chat, test, evaluate models with Ollama support, and use them in your AI applications.)
|
||||
- [MinimalNextOllamaChat](https://github.com/anilkay/MinimalNextOllamaChat) (Minimal Web UI for Chat and Model Control)
|
||||
- [Chipper](https://github.com/TilmanGriesel/chipper) AI interface for tinkerers (Ollama, Haystack RAG, Python)
|
||||
- [ChibiChat](https://github.com/CosmicEventHorizon/ChibiChat) (Kotlin-based Android app to chat with Ollama and Koboldcpp API endpoints)
|
||||
- [LocalLLM](https://github.com/qusaismael/localllm) (Minimal Web-App to run ollama models on it with a GUI)
|
||||
- [Ollamazing](https://github.com/buiducnhat/ollamazing) (Web extension to run Ollama models)
|
||||
- [OpenDeepResearcher-via-searxng](https://github.com/benhaotang/OpenDeepResearcher-via-searxng) (A Deep Research equivalent endpoint with Ollama support for running locally)
|
||||
- [AntSK](https://github.com/AIDotNet/AntSK) (Out-of-the-box & Adaptable RAG Chatbot)
|
||||
- [MaxKB](https://github.com/1Panel-dev/MaxKB/) (Ready-to-use & flexible RAG Chatbot)
|
||||
- [yla](https://github.com/danielekp/yla) (Web interface to freely interact with your customized models)
|
||||
- [LangBot](https://github.com/RockChinQ/LangBot) (LLM-based instant messaging bots platform, with Agents, RAG features, supports multiple platforms)
|
||||
- [1Panel](https://github.com/1Panel-dev/1Panel/) (Web-based Linux Server Management Tool)
|
||||
- [AstrBot](https://github.com/Soulter/AstrBot/) (User-friendly LLM-based multi-platform chatbot with a WebUI, supporting RAG, LLM agents, and plugins integration)
|
||||
- [Reins](https://github.com/ibrahimcetin/reins) (Easily tweak parameters, customize system prompts per chat, and enhance your AI experiments with reasoning model support.)
|
||||
- [Flufy](https://github.com/Aharon-Bensadoun/Flufy) (A beautiful chat interface for interacting with Ollama's API. Built with React, TypeScript, and Material-UI.)
|
||||
- [Ellama](https://github.com/zeozeozeo/ellama) (Friendly native app to chat with an Ollama instance)
|
||||
- [screenpipe](https://github.com/mediar-ai/screenpipe) Build agents powered by your screen history
|
||||
- [Ollamb](https://github.com/hengkysteen/ollamb) (Simple yet rich in features, cross-platform built with Flutter and designed for Ollama. Try the [web demo](https://hengkysteen.github.io/demo/ollamb/).)
|
||||
- [Writeopia](https://github.com/Writeopia/Writeopia) (Text editor with integration with Ollama)
|
||||
- [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) (AI collaborative workspace with Ollama, cross-platform and self-hostable)
|
||||
- [Lumina](https://github.com/cushydigit/lumina.git) (A lightweight, minimal React.js frontend for interacting with Ollama servers)
|
||||
- [Tiny Notepad](https://pypi.org/project/tiny-notepad) (A lightweight, notepad-like interface to chat with ollama available on PyPI)
|
||||
- [macLlama (macOS native)](https://github.com/hellotunamayo/macLlama) (A native macOS GUI application for interacting with Ollama models, featuring a chat interface.)
|
||||
- [GPTranslate](https://github.com/philberndt/GPTranslate) (A fast and lightweight, AI powered desktop translation application written with Rust and Tauri. Features real-time translation with OpenAI/Azure/Ollama.)
|
||||
- [ollama launcher](https://github.com/NGC13009/ollama-launcher) (A launcher for Ollama, aiming to provide users with convenient functions such as ollama server launching, management, or configuration.)
|
||||
- [ai-hub](https://github.com/Aj-Seven/ai-hub) (AI Hub supports multiple models via API keys and Chat support via Ollama API.)
|
||||
- [Mayan EDMS](https://gitlab.com/mayan-edms/mayan-edms) (Open source document management system to organize, tag, search, and automate your files with powerful Ollama driven workflows.)
|
||||
- [Serene Pub](https://github.com/doolijb/serene-pub) (Beginner friendly, open source AI Roleplaying App for Windows, Mac OS and Linux. Search, download and use models with Ollama all inside the app.)
|
||||
- [Andes](https://github.com/aqerd/andes) (A Visual Studio Code extension that provides a local UI interface for Ollama models)
|
||||
- [Clueless](https://github.com/KashyapTan/clueless) (Open Source & Local Cluely: A desktop application LLM assistant to help you talk to anything on your screen using locally served Ollama models. Also undetectable to screenshare)
|
||||
- [ollama-co2](https://github.com/carbonatedWaterOrg/ollama-co2) (FastAPI web interface for monitoring and managing local and remote Ollama servers with real-time model monitoring and concurrent downloads)
|
||||
### Chat Interfaces
|
||||
|
||||
### Cloud
|
||||
#### Web
|
||||
|
||||
- [Open WebUI](https://github.com/open-webui/open-webui) - Extensible, self-hosted AI interface
|
||||
- [Onyx](https://github.com/onyx-dot-app/onyx) - Connected AI workspace
|
||||
- [LibreChat](https://github.com/danny-avila/LibreChat) - Enhanced ChatGPT clone with multi-provider support
|
||||
- [Lobe Chat](https://github.com/lobehub/lobe-chat) - Modern chat framework with plugin ecosystem ([docs](https://lobehub.com/docs/self-hosting/examples/ollama))
|
||||
- [NextChat](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) - Cross-platform ChatGPT UI ([docs](https://docs.nextchat.dev/models/ollama))
|
||||
- [Perplexica](https://github.com/ItzCrazyKns/Perplexica) - AI-powered search engine, open-source Perplexity alternative
|
||||
- [big-AGI](https://github.com/enricoros/big-AGI) - AI suite for professionals
|
||||
- [Lollms WebUI](https://github.com/ParisNeo/lollms-webui) - Multi-model web interface
|
||||
- [ChatOllama](https://github.com/sugarforever/chat-ollama) - Chatbot with knowledge bases
|
||||
- [Bionic GPT](https://github.com/bionic-gpt/bionic-gpt) - On-premise AI platform
|
||||
- [Chatbot UI](https://github.com/ivanfioravanti/chatbot-ollama) - ChatGPT-style web interface
|
||||
- [Hollama](https://github.com/fmaclen/hollama) - Minimal web interface
|
||||
- [Chatbox](https://github.com/Bin-Huang/Chatbox) - Desktop and web AI client
|
||||
- [chat](https://github.com/swuecho/chat) - Chat web app for teams
|
||||
- [Ollama RAG Chatbot](https://github.com/datvodinh/rag-chatbot.git) - Chat with multiple PDFs using RAG
|
||||
- [Tkinter-based client](https://github.com/chyok/ollama-gui) - Python desktop client
|
||||
|
||||
#### Desktop
|
||||
|
||||
- [Dify.AI](https://github.com/langgenius/dify) - LLM app development platform
|
||||
- [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) - All-in-one AI app for Mac, Windows, and Linux
|
||||
- [Maid](https://github.com/Mobile-Artificial-Intelligence/maid) - Cross-platform mobile and desktop client
|
||||
- [Witsy](https://github.com/nbonamy/witsy) - AI desktop app for Mac, Windows, and Linux
|
||||
- [Cherry Studio](https://github.com/kangfenmao/cherry-studio) - Multi-provider desktop client
|
||||
- [Ollama App](https://github.com/JHubi1/ollama-app) - Multi-platform client for desktop and mobile
|
||||
- [PyGPT](https://github.com/szczyglis-dev/py-gpt) - AI desktop assistant for Linux, Windows, and Mac
|
||||
- [Alpaca](https://github.com/Jeffser/Alpaca) - GTK4 client for Linux and macOS
|
||||
- [SwiftChat](https://github.com/aws-samples/swift-chat) - Cross-platform including iOS, Android, and Apple Vision Pro
|
||||
- [Enchanted](https://github.com/AugustDev/enchanted) - Native macOS and iOS client
|
||||
- [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) - Multi-model desktop runner
|
||||
- [Ollama Grid Search](https://github.com/dezoito/ollama-grid-search) - Evaluate and compare models
|
||||
- [macai](https://github.com/Renset/macai) - macOS client for Ollama and ChatGPT
|
||||
- [AI Studio](https://github.com/MindWorkAI/AI-Studio) - Multi-provider desktop IDE
|
||||
- [Reins](https://github.com/ibrahimcetin/reins) - Parameter tuning and reasoning model support
|
||||
- [ConfiChat](https://github.com/1runeberg/confichat) - Privacy-focused with optional encryption
|
||||
- [LLocal.in](https://github.com/kartikm7/llocal) - Electron desktop client
|
||||
- [MindMac](https://mindmac.app) - AI chat client for Mac
|
||||
- [Msty](https://msty.app) - Multi-model desktop client
|
||||
- [BoltAI for Mac](https://boltai.com) - AI chat client for Mac
|
||||
- [IntelliBar](https://intellibar.app/) - AI-powered assistant for macOS
|
||||
- [Kerlig AI](https://www.kerlig.com/) - AI writing assistant for macOS
|
||||
- [Hillnote](https://hillnote.com) - Markdown-first AI workspace
|
||||
- [Perfect Memory AI](https://www.perfectmemory.ai/) - Productivity AI personalized by screen and meeting history
|
||||
|
||||
#### Mobile
|
||||
|
||||
- [Ollama Android Chat](https://github.com/sunshine0523/OllamaServer) - One-click Ollama on Android
|
||||
|
||||
> SwiftChat, Enchanted, Maid, Ollama App, Reins, and ConfiChat listed above also support mobile platforms.
|
||||
|
||||
### Code Editors & Development
|
||||
|
||||
- [Cline](https://github.com/cline/cline) - VS Code extension for multi-file/whole-repo coding
|
||||
- [Continue](https://github.com/continuedev/continue) - Open-source AI code assistant for any IDE
|
||||
- [Void](https://github.com/voideditor/void) - Open source AI code editor, Cursor alternative
|
||||
- [Copilot for Obsidian](https://github.com/logancyang/obsidian-copilot) - AI assistant for Obsidian
|
||||
- [twinny](https://github.com/rjmacarthy/twinny) - Copilot and Copilot chat alternative
|
||||
- [gptel Emacs client](https://github.com/karthink/gptel) - LLM client for Emacs
|
||||
- [Ollama Copilot](https://github.com/bernardo-bruning/ollama-copilot) - Use Ollama as GitHub Copilot
|
||||
- [Obsidian Local GPT](https://github.com/pfrankov/obsidian-local-gpt) - Local AI for Obsidian
|
||||
- [Ellama Emacs client](https://github.com/s-kostyaev/ellama) - LLM tool for Emacs
|
||||
- [orbiton](https://github.com/xyproto/orbiton) - Config-free text editor with Ollama tab completion
|
||||
- [AI ST Completion](https://github.com/yaroslavyaroslav/OpenAI-sublime-text) - Sublime Text 4 AI assistant
|
||||
- [VT Code](https://github.com/vinhnx/vtcode) - Rust-based terminal coding agent with Tree-sitter
|
||||
- [QodeAssist](https://github.com/Palm1r/QodeAssist) - AI coding assistant for Qt Creator
|
||||
- [AI Toolkit for VS Code](https://aka.ms/ai-tooklit/ollama-docs) - Microsoft-official VS Code extension
|
||||
- [Open Interpreter](https://docs.openinterpreter.com/language-model-setup/local-models/ollama) - Natural language interface for computers
|
||||
|
||||
### Libraries & SDKs
|
||||
|
||||
- [LiteLLM](https://github.com/BerriAI/litellm) - Unified API for 100+ LLM providers
|
||||
- [Semantic Kernel](https://github.com/microsoft/semantic-kernel/tree/main/python/semantic_kernel/connectors/ai/ollama) - Microsoft AI orchestration SDK
|
||||
- [LangChain4j](https://github.com/langchain4j/langchain4j) - Java LangChain ([example](https://github.com/langchain4j/langchain4j-examples/tree/main/ollama-examples/src/main/java))
|
||||
- [LangChainGo](https://github.com/tmc/langchaingo/) - Go LangChain ([example](https://github.com/tmc/langchaingo/tree/main/examples/ollama-completion-example))
|
||||
- [Spring AI](https://github.com/spring-projects/spring-ai) - Spring framework AI support ([docs](https://docs.spring.io/spring-ai/reference/api/chat/ollama-chat.html))
|
||||
- [LangChain](https://python.langchain.com/docs/integrations/chat/ollama/) and [LangChain.js](https://js.langchain.com/docs/integrations/chat/ollama/) with [example](https://js.langchain.com/docs/tutorials/local_rag/)
|
||||
- [Ollama for Ruby](https://github.com/crmne/ruby_llm) - Ruby LLM library
|
||||
- [any-llm](https://github.com/mozilla-ai/any-llm) - Unified LLM interface by Mozilla
|
||||
- [OllamaSharp for .NET](https://github.com/awaescher/OllamaSharp) - .NET SDK
|
||||
- [LangChainRust](https://github.com/Abraxas-365/langchain-rust) - Rust LangChain ([example](https://github.com/Abraxas-365/langchain-rust/blob/main/examples/llm_ollama.rs))
|
||||
- [Agents-Flex for Java](https://github.com/agents-flex/agents-flex) - Java agent framework ([example](https://github.com/agents-flex/agents-flex/tree/main/agents-flex-llm/agents-flex-llm-ollama/src/test/java/com/agentsflex/llm/ollama))
|
||||
- [Elixir LangChain](https://github.com/brainlid/langchain) - Elixir LangChain
|
||||
- [Ollama-rs for Rust](https://github.com/pepperoni21/ollama-rs) - Rust SDK
|
||||
- [LangChain for .NET](https://github.com/tryAGI/LangChain) - .NET LangChain ([example](https://github.com/tryAGI/LangChain/blob/main/examples/LangChain.Samples.OpenAI/Program.cs))
|
||||
- [chromem-go](https://github.com/philippgille/chromem-go) - Go vector database with Ollama embeddings ([example](https://github.com/philippgille/chromem-go/tree/v0.5.0/examples/rag-wikipedia-ollama))
|
||||
- [LangChainDart](https://github.com/davidmigloz/langchain_dart) - Dart LangChain
|
||||
- [LlmTornado](https://github.com/lofcz/llmtornado) - Unified C# interface for multiple inference APIs
|
||||
- [Ollama4j for Java](https://github.com/ollama4j/ollama4j) - Java SDK
|
||||
- [Ollama for Laravel](https://github.com/cloudstudio/ollama-laravel) - Laravel integration
|
||||
- [Ollama for Swift](https://github.com/mattt/ollama-swift) - Swift SDK
|
||||
- [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/llm/ollama/) and [LlamaIndexTS](https://ts.llamaindex.ai/modules/llms/available_llms/ollama) - Data framework for LLM apps
|
||||
- [Haystack](https://github.com/deepset-ai/haystack-integrations/blob/main/integrations/ollama.md) - AI pipeline framework
|
||||
- [Firebase Genkit](https://firebase.google.com/docs/genkit/plugins/ollama) - Google AI framework
|
||||
- [Ollama-hpp for C++](https://github.com/jmont-dev/ollama-hpp) - C++ SDK
|
||||
- [PromptingTools.jl](https://github.com/svilupp/PromptingTools.jl) - Julia LLM toolkit ([example](https://svilupp.github.io/PromptingTools.jl/dev/examples/working_with_ollama))
|
||||
- [Ollama for R - rollama](https://github.com/JBGruber/rollama) - R SDK
|
||||
- [Portkey](https://portkey.ai/docs/welcome/integration-guides/ollama) - AI gateway
|
||||
- [Testcontainers](https://testcontainers.com/modules/ollama/) - Container-based testing
|
||||
- [LLPhant](https://github.com/theodo-group/LLPhant?tab=readme-ov-file#ollama) - PHP AI framework
|
||||
|
||||
### Frameworks & Agents
|
||||
|
||||
- [AutoGPT](https://github.com/Significant-Gravitas/AutoGPT/blob/master/docs/content/platform/ollama.md) - Autonomous AI agent platform
|
||||
- [crewAI](https://github.com/crewAIInc/crewAI) - Multi-agent orchestration framework
|
||||
- [Strands Agents](https://github.com/strands-agents/sdk-python) - Model-driven agent building by AWS
|
||||
- [Cheshire Cat](https://github.com/cheshire-cat-ai/core) - AI assistant framework
|
||||
- [any-agent](https://github.com/mozilla-ai/any-agent) - Unified agent framework interface by Mozilla
|
||||
- [Stakpak](https://github.com/stakpak/agent) - Open source DevOps agent
|
||||
- [Hexabot](https://github.com/hexastack/hexabot) - Conversational AI builder
|
||||
- [Neuro SAN](https://github.com/cognizant-ai-lab/neuro-san-studio) - Multi-agent orchestration ([docs](https://github.com/cognizant-ai-lab/neuro-san-studio/blob/main/docs/user_guide.md#ollama))
|
||||
|
||||
### RAG & Knowledge Bases
|
||||
|
||||
- [RAGFlow](https://github.com/infiniflow/ragflow) - RAG engine based on deep document understanding
|
||||
- [R2R](https://github.com/SciPhi-AI/R2R) - Open-source RAG engine
|
||||
- [MaxKB](https://github.com/1Panel-dev/MaxKB/) - Ready-to-use RAG chatbot
|
||||
- [Minima](https://github.com/dmayboroda/minima) - On-premises or fully local RAG
|
||||
- [Chipper](https://github.com/TilmanGriesel/chipper) - AI interface with Haystack RAG
|
||||
- [ARGO](https://github.com/xark-argo/argo) - RAG and deep research on Mac/Windows/Linux
|
||||
- [Archyve](https://github.com/nickthecook/archyve) - RAG-enabling document library
|
||||
- [Casibase](https://casibase.org) - AI knowledge base with RAG and SSO
|
||||
- [BrainSoup](https://www.nurgo-software.com/products/brainsoup) - Native client with RAG and multi-agent automation
|
||||
|
||||
### Bots & Messaging
|
||||
|
||||
- [LangBot](https://github.com/RockChinQ/LangBot) - Multi-platform messaging bots with agents and RAG
|
||||
- [AstrBot](https://github.com/Soulter/AstrBot/) - Multi-platform chatbot with RAG and plugins
|
||||
- [Discord-Ollama Chat Bot](https://github.com/kevinthedang/discord-ollama) - TypeScript Discord bot
|
||||
- [Ollama Telegram Bot](https://github.com/ruecat/ollama-telegram) - Telegram bot
|
||||
- [LLM Telegram Bot](https://github.com/innightwolfsleep/llm_telegram_bot) - Telegram bot for roleplay
|
||||
|
||||
### Terminal & CLI
|
||||
|
||||
- [aichat](https://github.com/sigoden/aichat) - All-in-one LLM CLI with Shell Assistant, RAG, and AI tools
|
||||
- [oterm](https://github.com/ggozad/oterm) - Terminal client for Ollama
|
||||
- [gollama](https://github.com/sammcj/gollama) - Go-based model manager for Ollama
|
||||
- [tlm](https://github.com/yusufcanb/tlm) - Local shell copilot
|
||||
- [tenere](https://github.com/pythops/tenere) - TUI for LLMs
|
||||
- [ParLlama](https://github.com/paulrobello/parllama) - TUI for Ollama
|
||||
- [llm-ollama](https://github.com/taketwo/llm-ollama) - Plugin for [Datasette's LLM CLI](https://llm.datasette.io/en/stable/)
|
||||
- [ShellOracle](https://github.com/djcopley/ShellOracle) - Shell command suggestions
|
||||
- [LLM-X](https://github.com/mrdjohnson/llm-x) - Progressive web app for LLMs
|
||||
- [cmdh](https://github.com/pgibler/cmdh) - Natural language to shell commands
|
||||
- [VT](https://github.com/vinhnx/vt.ai) - Minimal multimodal AI chat app
|
||||
|
||||
### Productivity & Apps
|
||||
|
||||
- [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) - AI collaborative workspace, self-hostable Notion alternative
|
||||
- [Screenpipe](https://github.com/mediar-ai/screenpipe) - 24/7 screen and mic recording with AI-powered search
|
||||
- [Vibe](https://github.com/thewh1teagle/vibe) - Transcribe and analyze meetings
|
||||
- [Page Assist](https://github.com/n4ze3m/page-assist) - Chrome extension for AI-powered browsing
|
||||
- [NativeMind](https://github.com/NativeMindBrowser/NativeMindExtension) - Private, on-device browser AI assistant
|
||||
- [Ollama Fortress](https://github.com/ParisNeo/ollama_proxy_server) - Security proxy for Ollama
|
||||
- [1Panel](https://github.com/1Panel-dev/1Panel/) - Web-based Linux server management
|
||||
- [Writeopia](https://github.com/Writeopia/Writeopia) - Text editor with Ollama integration
|
||||
- [QA-Pilot](https://github.com/reid41/QA-Pilot) - GitHub code repository understanding
|
||||
- [Raycast extension](https://github.com/MassimilianoPasquini97/raycast_ollama) - Ollama in Raycast
|
||||
- [Painting Droid](https://github.com/mateuszmigas/painting-droid) - Painting app with AI integrations
|
||||
- [Serene Pub](https://github.com/doolijb/serene-pub) - AI roleplaying app
|
||||
- [Mayan EDMS](https://gitlab.com/mayan-edms/mayan-edms) - Document management with Ollama workflows
|
||||
- [TagSpaces](https://www.tagspaces.org) - File management with [AI tagging](https://docs.tagspaces.org/ai/)
|
||||
|
||||
### Observability & Monitoring
|
||||
|
||||
- [Opik](https://www.comet.com/docs/opik/cookbook/ollama) - Debug, evaluate, and monitor LLM applications
|
||||
- [OpenLIT](https://github.com/openlit/openlit) - OpenTelemetry-native monitoring for Ollama and GPUs
|
||||
- [Lunary](https://lunary.ai/docs/integrations/ollama) - LLM observability with analytics and PII masking
|
||||
- [Langfuse](https://langfuse.com/docs/integrations/ollama) - Open source LLM observability
|
||||
- [HoneyHive](https://docs.honeyhive.ai/integrations/ollama) - AI observability and evaluation for agents
|
||||
- [MLflow Tracing](https://mlflow.org/docs/latest/llms/tracing/index.html#automatic-tracing) - Open source LLM observability
|
||||
|
||||
### Database & Embeddings
|
||||
|
||||
- [pgai](https://github.com/timescale/pgai) - PostgreSQL as a vector database ([guide](https://github.com/timescale/pgai/blob/main/docs/vectorizer-quick-start.md))
|
||||
- [MindsDB](https://github.com/mindsdb/mindsdb/blob/staging/mindsdb/integrations/handlers/ollama_handler/README.md) - Connect Ollama with 200+ data platforms
|
||||
- [chromem-go](https://github.com/philippgille/chromem-go/blob/v0.5.0/embed_ollama.go) - Embeddable vector database for Go ([example](https://github.com/philippgille/chromem-go/tree/v0.5.0/examples/rag-wikipedia-ollama))
|
||||
- [Kangaroo](https://github.com/dbkangaroo/kangaroo) - AI-powered SQL client
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
#### Cloud
|
||||
|
||||
- [Google Cloud](https://cloud.google.com/run/docs/tutorials/gpu-gemma2-with-ollama)
|
||||
- [Fly.io](https://fly.io/docs/python/do-more/add-ollama/)
|
||||
- [Koyeb](https://www.koyeb.com/deploy/ollama)
|
||||
- [Harbor](https://github.com/av/harbor) - Containerized LLM toolkit with Ollama as default backend
|
||||
|
||||
### Terminal
|
||||
|
||||
- [oterm](https://github.com/ggozad/oterm)
|
||||
- [Ellama Emacs client](https://github.com/s-kostyaev/ellama)
|
||||
- [Emacs client](https://github.com/zweifisch/ollama)
|
||||
- [neollama](https://github.com/paradoxical-dev/neollama) UI client for interacting with models from within Neovim
|
||||
- [gen.nvim](https://github.com/David-Kunz/gen.nvim)
|
||||
- [ollama.nvim](https://github.com/nomnivore/ollama.nvim)
|
||||
- [ollero.nvim](https://github.com/marco-souza/ollero.nvim)
|
||||
- [ollama-chat.nvim](https://github.com/gerazov/ollama-chat.nvim)
|
||||
- [ogpt.nvim](https://github.com/huynle/ogpt.nvim)
|
||||
- [gptel Emacs client](https://github.com/karthink/gptel)
|
||||
- [Oatmeal](https://github.com/dustinblackman/oatmeal)
|
||||
- [cmdh](https://github.com/pgibler/cmdh)
|
||||
- [ooo](https://github.com/npahlfer/ooo)
|
||||
- [shell-pilot](https://github.com/reid41/shell-pilot)(Interact with models via pure shell scripts on Linux or macOS)
|
||||
- [tenere](https://github.com/pythops/tenere)
|
||||
- [llm-ollama](https://github.com/taketwo/llm-ollama) for [Datasette's LLM CLI](https://llm.datasette.io/en/stable/).
|
||||
- [typechat-cli](https://github.com/anaisbetts/typechat-cli)
|
||||
- [ShellOracle](https://github.com/djcopley/ShellOracle)
|
||||
- [tlm](https://github.com/yusufcanb/tlm)
|
||||
- [podman-ollama](https://github.com/ericcurtin/podman-ollama)
|
||||
- [gollama](https://github.com/sammcj/gollama)
|
||||
- [ParLlama](https://github.com/paulrobello/parllama)
|
||||
- [Ollama eBook Summary](https://github.com/cognitivetech/ollama-ebook-summary/)
|
||||
- [Ollama Mixture of Experts (MOE) in 50 lines of code](https://github.com/rapidarchitect/ollama_moe)
|
||||
- [vim-intelligence-bridge](https://github.com/pepo-ec/vim-intelligence-bridge) Simple interaction of "Ollama" with the Vim editor
|
||||
- [x-cmd ollama](https://x-cmd.com/mod/ollama)
|
||||
- [bb7](https://github.com/drunkwcodes/bb7)
|
||||
- [SwollamaCLI](https://github.com/marcusziade/Swollama) bundled with the Swollama Swift package. [Demo](https://github.com/marcusziade/Swollama?tab=readme-ov-file#cli-usage)
|
||||
- [aichat](https://github.com/sigoden/aichat) All-in-one LLM CLI tool featuring Shell Assistant, Chat-REPL, RAG, AI tools & agents, with access to OpenAI, Claude, Gemini, Ollama, Groq, and more.
|
||||
- [PowershAI](https://github.com/rrg92/powershai) PowerShell module that brings AI to terminal on Windows, including support for Ollama
|
||||
- [DeepShell](https://github.com/Abyss-c0re/deepshell) Your self-hosted AI assistant. Interactive Shell, Files and Folders analysis.
|
||||
- [orbiton](https://github.com/xyproto/orbiton) Configuration-free text editor and IDE with support for tab completion with Ollama.
|
||||
- [orca-cli](https://github.com/molbal/orca-cli) Ollama Registry CLI Application - Browse, pull, and download models from Ollama Registry in your terminal.
|
||||
- [GGUF-to-Ollama](https://github.com/jonathanhecl/gguf-to-ollama) - Importing GGUF to Ollama made easy (multiplatform)
|
||||
- [AWS-Strands-With-Ollama](https://github.com/rapidarchitect/ollama_strands) - AWS Strands Agents with Ollama Examples
|
||||
- [ollama-multirun](https://github.com/attogram/ollama-multirun) - A bash shell script to run a single prompt against any or all of your locally installed ollama models, saving the output and performance statistics as easily navigable web pages. ([Demo](https://attogram.github.io/ai_test_zone/))
|
||||
- [ollama-bash-toolshed](https://github.com/attogram/ollama-bash-toolshed) - Bash scripts to chat with tool using models. Add new tools to your shed with ease. Runs on Ollama.
|
||||
|
||||
### Apple Vision Pro
|
||||
|
||||
- [SwiftChat](https://github.com/aws-samples/swift-chat) (Cross-platform AI chat app supporting Apple Vision Pro via "Designed for iPad")
|
||||
- [Enchanted](https://github.com/AugustDev/enchanted)
|
||||
|
||||
### Database
|
||||
|
||||
- [pgai](https://github.com/timescale/pgai) - PostgreSQL as a vector database (Create and search embeddings from Ollama models using pgvector)
|
||||
- [Get started guide](https://github.com/timescale/pgai/blob/main/docs/vectorizer-quick-start.md)
|
||||
- [MindsDB](https://github.com/mindsdb/mindsdb/blob/staging/mindsdb/integrations/handlers/ollama_handler/README.md) (Connects Ollama models with nearly 200 data platforms and apps)
|
||||
- [chromem-go](https://github.com/philippgille/chromem-go/blob/v0.5.0/embed_ollama.go) with [example](https://github.com/philippgille/chromem-go/tree/v0.5.0/examples/rag-wikipedia-ollama)
|
||||
- [Kangaroo](https://github.com/dbkangaroo/kangaroo) (AI-powered SQL client and admin tool for popular databases)
|
||||
|
||||
### Package managers
|
||||
#### Package Managers
|
||||
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/ollama/)
|
||||
- [Gentoo](https://github.com/gentoo/guru/tree/master/app-misc/ollama)
|
||||
- [Homebrew](https://formulae.brew.sh/formula/ollama)
|
||||
- [Helm Chart](https://artifacthub.io/packages/helm/ollama-helm/ollama)
|
||||
- [Guix channel](https://codeberg.org/tusharhero/ollama-guix)
|
||||
- [Nix package](https://search.nixos.org/packages?show=ollama&from=0&size=50&sort=relevance&type=packages&query=ollama)
|
||||
- [Helm Chart](https://artifacthub.io/packages/helm/ollama-helm/ollama)
|
||||
- [Gentoo](https://github.com/gentoo/guru/tree/master/app-misc/ollama)
|
||||
- [Flox](https://flox.dev/blog/ollama-part-one)
|
||||
|
||||
### Libraries
|
||||
|
||||
- [LangChain](https://python.langchain.com/docs/integrations/chat/ollama/) and [LangChain.js](https://js.langchain.com/docs/integrations/chat/ollama/) with [example](https://js.langchain.com/docs/tutorials/local_rag/)
|
||||
- [Firebase Genkit](https://firebase.google.com/docs/genkit/plugins/ollama)
|
||||
- [crewAI](https://github.com/crewAIInc/crewAI)
|
||||
- [Yacana](https://remembersoftwares.github.io/yacana/) (User-friendly multi-agent framework for brainstorming and executing predetermined flows with built-in tool integration)
|
||||
- [Spring AI](https://github.com/spring-projects/spring-ai) with [reference](https://docs.spring.io/spring-ai/reference/api/chat/ollama-chat.html) and [example](https://github.com/tzolov/ollama-tools)
|
||||
- [LangChainGo](https://github.com/tmc/langchaingo/) with [example](https://github.com/tmc/langchaingo/tree/main/examples/ollama-completion-example)
|
||||
- [LangChain4j](https://github.com/langchain4j/langchain4j) with [example](https://github.com/langchain4j/langchain4j-examples/tree/main/ollama-examples/src/main/java)
|
||||
- [LangChainRust](https://github.com/Abraxas-365/langchain-rust) with [example](https://github.com/Abraxas-365/langchain-rust/blob/main/examples/llm_ollama.rs)
|
||||
- [LangChain for .NET](https://github.com/tryAGI/LangChain) with [example](https://github.com/tryAGI/LangChain/blob/main/examples/LangChain.Samples.OpenAI/Program.cs)
|
||||
- [LLPhant](https://github.com/theodo-group/LLPhant?tab=readme-ov-file#ollama)
|
||||
- [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/llm/ollama/) and [LlamaIndexTS](https://ts.llamaindex.ai/modules/llms/available_llms/ollama)
|
||||
- [LiteLLM](https://github.com/BerriAI/litellm)
|
||||
- [OllamaFarm for Go](https://github.com/presbrey/ollamafarm)
|
||||
- [OllamaSharp for .NET](https://github.com/awaescher/OllamaSharp)
|
||||
- [Ollama for Ruby](https://github.com/gbaptista/ollama-ai)
|
||||
- [Ollama-rs for Rust](https://github.com/pepperoni21/ollama-rs)
|
||||
- [Ollama-hpp for C++](https://github.com/jmont-dev/ollama-hpp)
|
||||
- [Ollama4j for Java](https://github.com/ollama4j/ollama4j)
|
||||
- [ModelFusion Typescript Library](https://modelfusion.dev/integration/model-provider/ollama)
|
||||
- [OllamaKit for Swift](https://github.com/kevinhermawan/OllamaKit)
|
||||
- [Ollama for Dart](https://github.com/breitburg/dart-ollama)
|
||||
- [Ollama for Laravel](https://github.com/cloudstudio/ollama-laravel)
|
||||
- [LangChainDart](https://github.com/davidmigloz/langchain_dart)
|
||||
- [Semantic Kernel - Python](https://github.com/microsoft/semantic-kernel/tree/main/python/semantic_kernel/connectors/ai/ollama)
|
||||
- [Haystack](https://github.com/deepset-ai/haystack-integrations/blob/main/integrations/ollama.md)
|
||||
- [Elixir LangChain](https://github.com/brainlid/langchain)
|
||||
- [Ollama for R - rollama](https://github.com/JBGruber/rollama)
|
||||
- [Ollama for R - ollama-r](https://github.com/hauselin/ollama-r)
|
||||
- [Ollama-ex for Elixir](https://github.com/lebrunel/ollama-ex)
|
||||
- [Ollama Connector for SAP ABAP](https://github.com/b-tocs/abap_btocs_ollama)
|
||||
- [Testcontainers](https://testcontainers.com/modules/ollama/)
|
||||
- [Portkey](https://portkey.ai/docs/welcome/integration-guides/ollama)
|
||||
- [PromptingTools.jl](https://github.com/svilupp/PromptingTools.jl) with an [example](https://svilupp.github.io/PromptingTools.jl/dev/examples/working_with_ollama)
|
||||
- [LlamaScript](https://github.com/Project-Llama/llamascript)
|
||||
- [llm-axe](https://github.com/emirsahin1/llm-axe) (Python Toolkit for Building LLM Powered Apps)
|
||||
- [Gollm](https://docs.gollm.co/examples/ollama-example)
|
||||
- [Gollama for Golang](https://github.com/jonathanhecl/gollama)
|
||||
- [Ollamaclient for Golang](https://github.com/xyproto/ollamaclient)
|
||||
- [High-level function abstraction in Go](https://gitlab.com/tozd/go/fun)
|
||||
- [Ollama PHP](https://github.com/ArdaGnsrn/ollama-php)
|
||||
- [Agents-Flex for Java](https://github.com/agents-flex/agents-flex) with [example](https://github.com/agents-flex/agents-flex/tree/main/agents-flex-llm/agents-flex-llm-ollama/src/test/java/com/agentsflex/llm/ollama)
|
||||
- [Parakeet](https://github.com/parakeet-nest/parakeet) is a GoLang library, made to simplify the development of small generative AI applications with Ollama.
|
||||
- [Haverscript](https://github.com/andygill/haverscript) with [examples](https://github.com/andygill/haverscript/tree/main/examples)
|
||||
- [Ollama for Swift](https://github.com/mattt/ollama-swift)
|
||||
- [Swollama for Swift](https://github.com/marcusziade/Swollama) with [DocC](https://marcusziade.github.io/Swollama/documentation/swollama/)
|
||||
- [GoLamify](https://github.com/prasad89/golamify)
|
||||
- [Ollama for Haskell](https://github.com/tusharad/ollama-haskell)
|
||||
- [multi-llm-ts](https://github.com/nbonamy/multi-llm-ts) (A Typescript/JavaScript library allowing access to different LLM in a unified API)
|
||||
- [LlmTornado](https://github.com/lofcz/llmtornado) (C# library providing a unified interface for major FOSS & Commercial inference APIs)
|
||||
- [Ollama for Zig](https://github.com/dravenk/ollama-zig)
|
||||
- [Abso](https://github.com/lunary-ai/abso) (OpenAI-compatible TypeScript SDK for any LLM provider)
|
||||
- [Nichey](https://github.com/goodreasonai/nichey) is a Python package for generating custom wikis for your research topic
|
||||
- [Ollama for D](https://github.com/kassane/ollama-d)
|
||||
- [OllamaPlusPlus](https://github.com/HardCodeDev777/OllamaPlusPlus) (Very simple C++ library for Ollama)
|
||||
- [any-llm](https://github.com/mozilla-ai/any-llm) (A single interface to use different llm providers by [mozilla.ai](https://www.mozilla.ai/))
|
||||
- [any-agent](https://github.com/mozilla-ai/any-agent) (A single interface to use and evaluate different agent frameworks by [mozilla.ai](https://www.mozilla.ai/))
|
||||
- [Neuro SAN](https://github.com/cognizant-ai-lab/neuro-san-studio) (Data-driven multi-agent orchestration framework) with [example](https://github.com/cognizant-ai-lab/neuro-san-studio/blob/main/docs/user_guide.md#ollama)
|
||||
- [achatbot-go](https://github.com/ai-bot-pro/achatbot-go) a multimodal(text/audio/image) chatbot.
|
||||
|
||||
### Mobile
|
||||
|
||||
- [SwiftChat](https://github.com/aws-samples/swift-chat) (Lightning-fast Cross-platform AI chat app with native UI for Android, iOS, and iPad)
|
||||
- [Enchanted](https://github.com/AugustDev/enchanted)
|
||||
- [Maid](https://github.com/Mobile-Artificial-Intelligence/maid)
|
||||
- [Ollama App](https://github.com/JHubi1/ollama-app) (Modern and easy-to-use multi-platform client for Ollama)
|
||||
- [ConfiChat](https://github.com/1runeberg/confichat) (Lightweight, standalone, multi-platform, and privacy-focused LLM chat interface with optional encryption)
|
||||
- [Ollama Android Chat](https://github.com/sunshine0523/OllamaServer) (No need for Termux, start the Ollama service with one click on an Android device)
|
||||
- [Reins](https://github.com/ibrahimcetin/reins) (Easily tweak parameters, customize system prompts per chat, and enhance your AI experiments with reasoning model support.)
|
||||
|
||||
### Extensions & Plugins
|
||||
|
||||
- [Raycast extension](https://github.com/MassimilianoPasquini97/raycast_ollama)
|
||||
- [Discollama](https://github.com/mxyng/discollama) (Discord bot inside the Ollama discord channel)
|
||||
- [Continue](https://github.com/continuedev/continue)
|
||||
- [Vibe](https://github.com/thewh1teagle/vibe) (Transcribe and analyze meetings with Ollama)
|
||||
- [Obsidian Ollama plugin](https://github.com/hinterdupfinger/obsidian-ollama)
|
||||
- [Logseq Ollama plugin](https://github.com/omagdy7/ollama-logseq)
|
||||
- [NotesOllama](https://github.com/andersrex/notesollama) (Apple Notes Ollama plugin)
|
||||
- [Dagger Chatbot](https://github.com/samalba/dagger-chatbot)
|
||||
- [Discord AI Bot](https://github.com/mekb-turtle/discord-ai-bot)
|
||||
- [Ollama Telegram Bot](https://github.com/ruecat/ollama-telegram)
|
||||
- [Hass Ollama Conversation](https://github.com/ej52/hass-ollama-conversation)
|
||||
- [Rivet plugin](https://github.com/abrenneke/rivet-plugin-ollama)
|
||||
- [Obsidian BMO Chatbot plugin](https://github.com/longy2k/obsidian-bmo-chatbot)
|
||||
- [Cliobot](https://github.com/herval/cliobot) (Telegram bot with Ollama support)
|
||||
- [Copilot for Obsidian plugin](https://github.com/logancyang/obsidian-copilot)
|
||||
- [Obsidian Local GPT plugin](https://github.com/pfrankov/obsidian-local-gpt)
|
||||
- [Open Interpreter](https://docs.openinterpreter.com/language-model-setup/local-models/ollama)
|
||||
- [Llama Coder](https://github.com/ex3ndr/llama-coder) (Copilot alternative using Ollama)
|
||||
- [Ollama Copilot](https://github.com/bernardo-bruning/ollama-copilot) (Proxy that allows you to use Ollama as a copilot like GitHub Copilot)
|
||||
- [twinny](https://github.com/rjmacarthy/twinny) (Copilot and Copilot chat alternative using Ollama)
|
||||
- [Wingman-AI](https://github.com/RussellCanfield/wingman-ai) (Copilot code and chat alternative using Ollama and Hugging Face)
|
||||
- [Page Assist](https://github.com/n4ze3m/page-assist) (Chrome Extension)
|
||||
- [Plasmoid Ollama Control](https://github.com/imoize/plasmoid-ollamacontrol) (KDE Plasma extension that allows you to quickly manage/control Ollama model)
|
||||
- [AI Telegram Bot](https://github.com/tusharhero/aitelegrambot) (Telegram bot using Ollama in backend)
|
||||
- [AI ST Completion](https://github.com/yaroslavyaroslav/OpenAI-sublime-text) (Sublime Text 4 AI assistant plugin with Ollama support)
|
||||
- [Discord-Ollama Chat Bot](https://github.com/kevinthedang/discord-ollama) (Generalized TypeScript Discord Bot w/ Tuning Documentation)
|
||||
- [ChatGPTBox: All in one browser extension](https://github.com/josStorer/chatGPTBox) with [Integrating Tutorial](https://github.com/josStorer/chatGPTBox/issues/616#issuecomment-1975186467)
|
||||
- [Discord AI chat/moderation bot](https://github.com/rapmd73/Companion) Chat/moderation bot written in python. Uses Ollama to create personalities.
|
||||
- [Headless Ollama](https://github.com/nischalj10/headless-ollama) (Scripts to automatically install ollama client & models on any OS for apps that depend on ollama server)
|
||||
- [Terraform AWS Ollama & Open WebUI](https://github.com/xuyangbocn/terraform-aws-self-host-llm) (A Terraform module to deploy on AWS a ready-to-use Ollama service, together with its front-end Open WebUI service.)
|
||||
- [node-red-contrib-ollama](https://github.com/jakubburkiewicz/node-red-contrib-ollama)
|
||||
- [Local AI Helper](https://github.com/ivostoykov/localAI) (Chrome and Firefox extensions that enable interactions with the active tab and customisable API endpoints. Includes secure storage for user prompts.)
|
||||
- [vnc-lm](https://github.com/jake83741/vnc-lm) (Discord bot for messaging with LLMs through Ollama and LiteLLM. Seamlessly move between local and flagship models.)
|
||||
- [LSP-AI](https://github.com/SilasMarvin/lsp-ai) (Open-source language server for AI-powered functionality)
|
||||
- [QodeAssist](https://github.com/Palm1r/QodeAssist) (AI-powered coding assistant plugin for Qt Creator)
|
||||
- [Obsidian Quiz Generator plugin](https://github.com/ECuiDev/obsidian-quiz-generator)
|
||||
- [AI Summmary Helper plugin](https://github.com/philffm/ai-summary-helper)
|
||||
- [TextCraft](https://github.com/suncloudsmoon/TextCraft) (Copilot in Word alternative using Ollama)
|
||||
- [Alfred Ollama](https://github.com/zeitlings/alfred-ollama) (Alfred Workflow)
|
||||
- [TextLLaMA](https://github.com/adarshM84/TextLLaMA) A Chrome Extension that helps you write emails, correct grammar, and translate into any language
|
||||
- [Simple-Discord-AI](https://github.com/zyphixor/simple-discord-ai)
|
||||
- [LLM Telegram Bot](https://github.com/innightwolfsleep/llm_telegram_bot) (telegram bot, primary for RP. Oobabooga-like buttons, [A1111](https://github.com/AUTOMATIC1111/stable-diffusion-webui) API integration e.t.c)
|
||||
- [mcp-llm](https://github.com/sammcj/mcp-llm) (MCP Server to allow LLMs to call other LLMs)
|
||||
- [SimpleOllamaUnity](https://github.com/HardCodeDev777/SimpleOllamaUnity) (Unity Engine extension for communicating with Ollama in a few lines of code. Also works at runtime)
|
||||
- [UnityCodeLama](https://github.com/HardCodeDev777/UnityCodeLama) (Unity Edtior tool to analyze scripts via Ollama)
|
||||
- [NativeMind](https://github.com/NativeMindBrowser/NativeMindExtension) (Private, on-device AI Assistant, no cloud dependencies)
|
||||
- [GMAI - Gradle Managed AI](https://gmai.premex.se/) (Gradle plugin for automated Ollama lifecycle management during build phases)
|
||||
- [NOMYO Router](https://github.com/nomyo-ai/nomyo-router) (A transparent Ollama proxy with model deployment aware routing which auto-manages multiple Ollama instances in a given network)
|
||||
|
||||
### Supported backends
|
||||
|
||||
- [llama.cpp](https://github.com/ggml-org/llama.cpp) project founded by Georgi Gerganov.
|
||||
|
||||
### Observability
|
||||
- [Opik](https://www.comet.com/docs/opik/cookbook/ollama) is an open-source platform to debug, evaluate, and monitor your LLM applications, RAG systems, and agentic workflows with comprehensive tracing, automated evaluations, and production-ready dashboards. Opik supports native intergration to Ollama.
|
||||
- [Lunary](https://lunary.ai/docs/integrations/ollama) is the leading open-source LLM observability platform. It provides a variety of enterprise-grade features such as real-time analytics, prompt templates management, PII masking, and comprehensive agent tracing.
|
||||
- [OpenLIT](https://github.com/openlit/openlit) is an OpenTelemetry-native tool for monitoring Ollama Applications & GPUs using traces and metrics.
|
||||
- [HoneyHive](https://docs.honeyhive.ai/integrations/ollama) is an AI observability and evaluation platform for AI agents. Use HoneyHive to evaluate agent performance, interrogate failures, and monitor quality in production.
|
||||
- [Langfuse](https://langfuse.com/docs/integrations/ollama) is an open source LLM observability platform that enables teams to collaboratively monitor, evaluate and debug AI applications.
|
||||
- [MLflow Tracing](https://mlflow.org/docs/latest/llms/tracing/index.html#automatic-tracing) is an open source LLM observability tool with a convenient API to log and visualize traces, making it easy to debug and evaluate GenAI applications.
|
||||
- [Guix channel](https://codeberg.org/tusharhero/ollama-guix)
|
||||
|
||||
@@ -14,7 +14,7 @@ Please include the following details in your report:
|
||||
|
||||
## Security best practices
|
||||
|
||||
While the maintainer team does their best to secure Ollama, users are encouraged to implement their own security best practices, such as:
|
||||
While the maintainer team does its best to secure Ollama, users are encouraged to implement their own security best practices, such as:
|
||||
|
||||
- Regularly updating to the latest version of Ollama
|
||||
- Securing access to hosted instances of Ollama
|
||||
|
||||
1210
anthropic/anthropic.go
Executable file
1210
anthropic/anthropic.go
Executable file
File diff suppressed because it is too large
Load Diff
1455
anthropic/anthropic_test.go
Executable file
1455
anthropic/anthropic_test.go
Executable file
File diff suppressed because it is too large
Load Diff
352
anthropic/trace.go
Normal file
352
anthropic/trace.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// Trace truncation limits.
|
||||
const (
|
||||
TraceMaxStringRunes = 240
|
||||
TraceMaxSliceItems = 8
|
||||
TraceMaxMapEntries = 16
|
||||
TraceMaxDepth = 4
|
||||
)
|
||||
|
||||
// TraceTruncateString shortens s to TraceMaxStringRunes, appending a count of
|
||||
// omitted characters when truncated.
|
||||
func TraceTruncateString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= TraceMaxStringRunes {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%s...(+%d chars)", string(runes[:TraceMaxStringRunes]), len(runes)-TraceMaxStringRunes)
|
||||
}
|
||||
|
||||
// TraceJSON round-trips v through JSON and returns a compacted representation.
|
||||
func TraceJSON(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return map[string]any{"marshal_error": err.Error(), "type": fmt.Sprintf("%T", v)}
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return TraceTruncateString(string(data))
|
||||
}
|
||||
return TraceCompactValue(out, 0)
|
||||
}
|
||||
|
||||
// TraceCompactValue recursively truncates strings, slices, and maps for trace
|
||||
// output. depth tracks recursion to enforce TraceMaxDepth.
|
||||
func TraceCompactValue(v any, depth int) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if depth >= TraceMaxDepth {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return TraceTruncateString(t)
|
||||
case []any:
|
||||
return fmt.Sprintf("<array len=%d>", len(t))
|
||||
case map[string]any:
|
||||
return fmt.Sprintf("<object keys=%d>", len(t))
|
||||
default:
|
||||
return fmt.Sprintf("<%T>", v)
|
||||
}
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return TraceTruncateString(t)
|
||||
case []any:
|
||||
limit := min(len(t), TraceMaxSliceItems)
|
||||
out := make([]any, 0, limit+1)
|
||||
for i := range limit {
|
||||
out = append(out, TraceCompactValue(t[i], depth+1))
|
||||
}
|
||||
if len(t) > limit {
|
||||
out = append(out, fmt.Sprintf("... +%d more items", len(t)-limit))
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
limit := min(len(keys), TraceMaxMapEntries)
|
||||
out := make(map[string]any, limit+1)
|
||||
for i := range limit {
|
||||
out[keys[i]] = TraceCompactValue(t[keys[i]], depth+1)
|
||||
}
|
||||
if len(keys) > limit {
|
||||
out["__truncated_keys"] = len(keys) - limit
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic request/response tracing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TraceMessagesRequest returns a compact trace representation of a MessagesRequest.
|
||||
func TraceMessagesRequest(r MessagesRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"model": r.Model,
|
||||
"max_tokens": r.MaxTokens,
|
||||
"messages": traceMessageParams(r.Messages),
|
||||
"system": traceAnthropicContent(r.System),
|
||||
"stream": r.Stream,
|
||||
"tools": traceTools(r.Tools),
|
||||
"tool_choice": TraceJSON(r.ToolChoice),
|
||||
"thinking": TraceJSON(r.Thinking),
|
||||
"stop_sequences": r.StopSequences,
|
||||
"temperature": ptrVal(r.Temperature),
|
||||
"top_p": ptrVal(r.TopP),
|
||||
"top_k": ptrVal(r.TopK),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceMessagesResponse returns a compact trace representation of a MessagesResponse.
|
||||
func TraceMessagesResponse(r MessagesResponse) map[string]any {
|
||||
return map[string]any{
|
||||
"id": r.ID,
|
||||
"model": r.Model,
|
||||
"content": TraceJSON(r.Content),
|
||||
"stop_reason": r.StopReason,
|
||||
"usage": r.Usage,
|
||||
}
|
||||
}
|
||||
|
||||
func traceMessageParams(msgs []MessageParam) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, map[string]any{
|
||||
"role": m.Role,
|
||||
"content": traceAnthropicContent(m.Content),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceAnthropicContent(content any) any {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
return TraceTruncateString(c)
|
||||
case []any:
|
||||
blocks := make([]any, 0, len(c))
|
||||
for _, block := range c {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
blocks = append(blocks, TraceCompactValue(block, 0))
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, traceAnthropicBlock(blockMap))
|
||||
}
|
||||
return blocks
|
||||
default:
|
||||
return TraceJSON(c)
|
||||
}
|
||||
}
|
||||
|
||||
func traceAnthropicBlock(block map[string]any) map[string]any {
|
||||
blockType, _ := block["type"].(string)
|
||||
out := map[string]any{"type": blockType}
|
||||
switch blockType {
|
||||
case "text":
|
||||
if text, ok := block["text"].(string); ok {
|
||||
out["text"] = TraceTruncateString(text)
|
||||
} else {
|
||||
out["text"] = TraceCompactValue(block["text"], 0)
|
||||
}
|
||||
case "thinking":
|
||||
if thinking, ok := block["thinking"].(string); ok {
|
||||
out["thinking"] = TraceTruncateString(thinking)
|
||||
} else {
|
||||
out["thinking"] = TraceCompactValue(block["thinking"], 0)
|
||||
}
|
||||
case "tool_use", "server_tool_use":
|
||||
out["id"] = block["id"]
|
||||
out["name"] = block["name"]
|
||||
out["input"] = TraceCompactValue(block["input"], 0)
|
||||
case "tool_result", "web_search_tool_result":
|
||||
out["tool_use_id"] = block["tool_use_id"]
|
||||
out["content"] = TraceCompactValue(block["content"], 0)
|
||||
case "image":
|
||||
if source, ok := block["source"].(map[string]any); ok {
|
||||
out["source"] = map[string]any{
|
||||
"type": source["type"],
|
||||
"media_type": source["media_type"],
|
||||
"url": source["url"],
|
||||
"data_len": len(fmt.Sprint(source["data"])),
|
||||
}
|
||||
}
|
||||
default:
|
||||
out["block"] = TraceCompactValue(block, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceTools(tools []Tool) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, TraceTool(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceTool returns a compact trace representation of an Anthropic Tool.
|
||||
func TraceTool(t Tool) map[string]any {
|
||||
return map[string]any{
|
||||
"type": t.Type,
|
||||
"name": t.Name,
|
||||
"description": TraceTruncateString(t.Description),
|
||||
"input_schema": TraceJSON(t.InputSchema),
|
||||
"max_uses": t.MaxUses,
|
||||
}
|
||||
}
|
||||
|
||||
// ContentBlockTypes returns the type strings from content (when it's []any blocks).
|
||||
func ContentBlockTypes(content any) []string {
|
||||
blocks, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
types := make([]string, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
types = append(types, fmt.Sprintf("%T", block))
|
||||
continue
|
||||
}
|
||||
t, _ := blockMap["type"].(string)
|
||||
types = append(types, t)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func ptrVal[T any](v *T) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ollama api.* tracing (shared between anthropic and middleware packages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TraceChatRequest returns a compact trace representation of an Ollama ChatRequest.
|
||||
func TraceChatRequest(req *api.ChatRequest) map[string]any {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
stream := false
|
||||
if req.Stream != nil {
|
||||
stream = *req.Stream
|
||||
}
|
||||
return map[string]any{
|
||||
"model": req.Model,
|
||||
"messages": TraceAPIMessages(req.Messages),
|
||||
"tools": TraceAPITools(req.Tools),
|
||||
"stream": stream,
|
||||
"options": req.Options,
|
||||
"think": TraceJSON(req.Think),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceChatResponse returns a compact trace representation of an Ollama ChatResponse.
|
||||
func TraceChatResponse(resp api.ChatResponse) map[string]any {
|
||||
return map[string]any{
|
||||
"model": resp.Model,
|
||||
"done": resp.Done,
|
||||
"done_reason": resp.DoneReason,
|
||||
"message": TraceAPIMessage(resp.Message),
|
||||
"metrics": TraceJSON(resp.Metrics),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceAPIMessages returns compact trace representations for a slice of api.Message.
|
||||
func TraceAPIMessages(msgs []api.Message) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, TraceAPIMessage(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceAPIMessage returns a compact trace representation of a single api.Message.
|
||||
func TraceAPIMessage(m api.Message) map[string]any {
|
||||
return map[string]any{
|
||||
"role": m.Role,
|
||||
"content": TraceTruncateString(m.Content),
|
||||
"thinking": TraceTruncateString(m.Thinking),
|
||||
"images": traceImageSizes(m.Images),
|
||||
"tool_calls": traceToolCalls(m.ToolCalls),
|
||||
"tool_name": m.ToolName,
|
||||
"tool_call_id": m.ToolCallID,
|
||||
}
|
||||
}
|
||||
|
||||
func traceImageSizes(images []api.ImageData) []int {
|
||||
if len(images) == 0 {
|
||||
return nil
|
||||
}
|
||||
sizes := make([]int, 0, len(images))
|
||||
for _, img := range images {
|
||||
sizes = append(sizes, len(img))
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
// TraceAPITools returns compact trace representations for a slice of api.Tool.
|
||||
func TraceAPITools(tools api.Tools) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, TraceAPITool(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceAPITool returns a compact trace representation of a single api.Tool.
|
||||
func TraceAPITool(t api.Tool) map[string]any {
|
||||
return map[string]any{
|
||||
"type": t.Type,
|
||||
"name": t.Function.Name,
|
||||
"description": TraceTruncateString(t.Function.Description),
|
||||
"parameters": TraceJSON(t.Function.Parameters),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceToolCall returns a compact trace representation of an api.ToolCall.
|
||||
func TraceToolCall(tc api.ToolCall) map[string]any {
|
||||
return map[string]any{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"args": TraceJSON(tc.Function.Arguments),
|
||||
}
|
||||
}
|
||||
|
||||
func traceToolCalls(tcs []api.ToolCall) []map[string]any {
|
||||
if len(tcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(tcs))
|
||||
for _, tc := range tcs {
|
||||
out = append(out, TraceToolCall(tc))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (c *Client) do(ctx context.Context, method, path string, reqData, respData
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxBufferSize = 512 * format.KiloByte
|
||||
const maxBufferSize = 8 * format.MegaByte
|
||||
|
||||
func (c *Client) stream(ctx context.Context, method, path string, data any, fn func([]byte) error) error {
|
||||
var buf io.Reader
|
||||
@@ -226,7 +226,14 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
|
||||
bts := scanner.Bytes()
|
||||
if err := json.Unmarshal(bts, &errorResponse); err != nil {
|
||||
return fmt.Errorf("unmarshal: %w", err)
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
return StatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
Status: response.Status,
|
||||
ErrorMessage: string(bts),
|
||||
}
|
||||
}
|
||||
return errors.New(string(bts))
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusUnauthorized {
|
||||
@@ -340,7 +347,7 @@ type CreateProgressFunc func(ProgressResponse) error
|
||||
// Create creates a model from a [Modelfile]. fn is a progress function that
|
||||
// behaves similarly to other methods (see [Client.Pull]).
|
||||
//
|
||||
// [Modelfile]: https://github.com/ollama/ollama/blob/main/docs/modelfile.md
|
||||
// [Modelfile]: https://github.com/ollama/ollama/blob/main/docs/modelfile.mdx
|
||||
func (c *Client) Create(ctx context.Context, req *CreateRequest, fn CreateProgressFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/create", req, func(bts []byte) error {
|
||||
var resp ProgressResponse
|
||||
@@ -442,6 +449,16 @@ func (c *Client) Version(ctx context.Context) (string, error) {
|
||||
return version.Version, nil
|
||||
}
|
||||
|
||||
// CloudStatusExperimental returns whether cloud features are disabled on the server.
|
||||
func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse, error) {
|
||||
var status StatusResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/status", nil, &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &status, 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)
|
||||
@@ -459,3 +476,25 @@ func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) {
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// AliasRequest is the request body for creating or updating a model alias.
|
||||
type AliasRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
Target string `json:"target"`
|
||||
PrefixMatching bool `json:"prefix_matching,omitempty"`
|
||||
}
|
||||
|
||||
// SetAliasExperimental creates or updates a model alias via the experimental aliases API.
|
||||
func (c *Client) SetAliasExperimental(ctx context.Context, req *AliasRequest) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/experimental/aliases", req, nil)
|
||||
}
|
||||
|
||||
// AliasDeleteRequest is the request body for deleting a model alias.
|
||||
type AliasDeleteRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// DeleteAliasExperimental deletes a model alias via the experimental aliases API.
|
||||
func (c *Client) DeleteAliasExperimental(ctx context.Context, req *AliasDeleteRequest) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/experimental/aliases", req, nil)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ func TestClientFromEnvironment(t *testing.T) {
|
||||
type testError struct {
|
||||
message string
|
||||
statusCode int
|
||||
raw bool // if true, write message as-is instead of JSON encoding
|
||||
}
|
||||
|
||||
func (e testError) Error() string {
|
||||
@@ -111,6 +112,20 @@ func TestClientStream(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plain text error response",
|
||||
responses: []any{
|
||||
"internal server error",
|
||||
},
|
||||
wantErr: "internal server error",
|
||||
},
|
||||
{
|
||||
name: "HTML error page",
|
||||
responses: []any{
|
||||
"<html><body>404 Not Found</body></html>",
|
||||
},
|
||||
wantErr: "404 Not Found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -135,6 +150,12 @@ func TestClientStream(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if str, ok := resp.(string); ok {
|
||||
fmt.Fprintln(w, str)
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
t.Fatalf("failed to encode response: %v", err)
|
||||
}
|
||||
@@ -173,9 +194,10 @@ func TestClientStream(t *testing.T) {
|
||||
|
||||
func TestClientDo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
response any
|
||||
wantErr string
|
||||
name string
|
||||
response any
|
||||
wantErr string
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "immediate error response",
|
||||
@@ -183,7 +205,8 @@ func TestClientDo(t *testing.T) {
|
||||
message: "test error message",
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
wantErr: "test error message",
|
||||
wantErr: "test error message",
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "server error response",
|
||||
@@ -191,7 +214,8 @@ func TestClientDo(t *testing.T) {
|
||||
message: "internal error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
},
|
||||
wantErr: "internal error",
|
||||
wantErr: "internal error",
|
||||
wantStatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
@@ -203,6 +227,26 @@ func TestClientDo(t *testing.T) {
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plain text error response",
|
||||
response: testError{
|
||||
message: "internal server error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
raw: true,
|
||||
},
|
||||
wantErr: "internal server error",
|
||||
wantStatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "HTML error page",
|
||||
response: testError{
|
||||
message: "<html><body>404 Not Found</body></html>",
|
||||
statusCode: http.StatusNotFound,
|
||||
raw: true,
|
||||
},
|
||||
wantErr: "<html><body>404 Not Found</body></html>",
|
||||
wantStatusCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -210,11 +254,16 @@ func TestClientDo(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if errResp, ok := tc.response.(testError); ok {
|
||||
w.WriteHeader(errResp.statusCode)
|
||||
err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": errResp.message,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("failed to encode error response:", err)
|
||||
if !errResp.raw {
|
||||
err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": errResp.message,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("failed to encode error response:", err)
|
||||
}
|
||||
} else {
|
||||
// Write raw message (simulates non-JSON error responses)
|
||||
fmt.Fprint(w, errResp.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -241,6 +290,15 @@ func TestClientDo(t *testing.T) {
|
||||
if err.Error() != tc.wantErr {
|
||||
t.Errorf("error message mismatch: got %q, want %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
if tc.wantStatusCode != 0 {
|
||||
if statusErr, ok := err.(StatusError); ok {
|
||||
if statusErr.StatusCode != tc.wantStatusCode {
|
||||
t.Errorf("status code mismatch: got %d, want %d", statusErr.StatusCode, tc.wantStatusCode)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("expected StatusError, got %T", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,19 @@ func main() {
|
||||
}
|
||||
|
||||
messages := []api.Message{
|
||||
api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Provide very brief, concise responses",
|
||||
},
|
||||
api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Name some unusual animals",
|
||||
},
|
||||
api.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Monotreme, platypus, echidna",
|
||||
},
|
||||
api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "which of these is the most dangerous?",
|
||||
},
|
||||
|
||||
257
api/types.go
257
api/types.go
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/internal/orderedmap"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
@@ -117,6 +119,28 @@ type GenerateRequest struct {
|
||||
// DebugRenderOnly is a debug option that, when set to true, returns the rendered
|
||||
// template instead of calling the model.
|
||||
DebugRenderOnly bool `json:"_debug_render_only,omitempty"`
|
||||
|
||||
// Logprobs specifies whether to return log probabilities of the output tokens.
|
||||
Logprobs bool `json:"logprobs,omitempty"`
|
||||
|
||||
// TopLogprobs is the number of most likely tokens to return at each token position,
|
||||
// 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].
|
||||
@@ -159,6 +183,14 @@ type ChatRequest struct {
|
||||
// DebugRenderOnly is a debug option that, when set to true, returns the rendered
|
||||
// template instead of calling the model.
|
||||
DebugRenderOnly bool `json:"_debug_render_only,omitempty"`
|
||||
|
||||
// Logprobs specifies whether to return log probabilities of the output tokens.
|
||||
Logprobs bool `json:"logprobs,omitempty"`
|
||||
|
||||
// TopLogprobs is the number of most likely tokens to return at each token position,
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type Tools []Tool
|
||||
@@ -181,10 +213,11 @@ type Message struct {
|
||||
Content string `json:"content"`
|
||||
// Thinking contains the text that was inside thinking tags in the
|
||||
// original model output when ChatRequest.Think is enabled.
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Images []ImageData `json:"images,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Images []ImageData `json:"images,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Message) UnmarshalJSON(b []byte) error {
|
||||
@@ -200,6 +233,7 @@ func (m *Message) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Function ToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
@@ -209,13 +243,79 @@ type ToolCallFunction struct {
|
||||
Arguments ToolCallFunctionArguments `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolCallFunctionArguments map[string]any
|
||||
// ToolCallFunctionArguments holds tool call arguments in insertion order.
|
||||
type ToolCallFunctionArguments struct {
|
||||
om *orderedmap.Map[string, any]
|
||||
}
|
||||
|
||||
// NewToolCallFunctionArguments creates a new empty ToolCallFunctionArguments.
|
||||
func NewToolCallFunctionArguments() ToolCallFunctionArguments {
|
||||
return ToolCallFunctionArguments{om: orderedmap.New[string, any]()}
|
||||
}
|
||||
|
||||
// Get retrieves a value by key.
|
||||
func (t *ToolCallFunctionArguments) Get(key string) (any, bool) {
|
||||
if t == nil || t.om == nil {
|
||||
return nil, false
|
||||
}
|
||||
return t.om.Get(key)
|
||||
}
|
||||
|
||||
// Set sets a key-value pair, preserving insertion order.
|
||||
func (t *ToolCallFunctionArguments) Set(key string, value any) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if t.om == nil {
|
||||
t.om = orderedmap.New[string, any]()
|
||||
}
|
||||
t.om.Set(key, value)
|
||||
}
|
||||
|
||||
// Len returns the number of arguments.
|
||||
func (t *ToolCallFunctionArguments) Len() int {
|
||||
if t == nil || t.om == nil {
|
||||
return 0
|
||||
}
|
||||
return t.om.Len()
|
||||
}
|
||||
|
||||
// All returns an iterator over all key-value pairs in insertion order.
|
||||
func (t *ToolCallFunctionArguments) All() iter.Seq2[string, any] {
|
||||
if t == nil || t.om == nil {
|
||||
return func(yield func(string, any) bool) {}
|
||||
}
|
||||
return t.om.All()
|
||||
}
|
||||
|
||||
// ToMap returns a regular map (order not preserved).
|
||||
func (t *ToolCallFunctionArguments) ToMap() map[string]any {
|
||||
if t == nil || t.om == nil {
|
||||
return nil
|
||||
}
|
||||
return t.om.ToMap()
|
||||
}
|
||||
|
||||
func (t *ToolCallFunctionArguments) String() string {
|
||||
bts, _ := json.Marshal(t)
|
||||
if t == nil || t.om == nil {
|
||||
return "{}"
|
||||
}
|
||||
bts, _ := json.Marshal(t.om)
|
||||
return string(bts)
|
||||
}
|
||||
|
||||
func (t *ToolCallFunctionArguments) UnmarshalJSON(data []byte) error {
|
||||
t.om = orderedmap.New[string, any]()
|
||||
return json.Unmarshal(data, t.om)
|
||||
}
|
||||
|
||||
func (t ToolCallFunctionArguments) MarshalJSON() ([]byte, error) {
|
||||
if t.om == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return json.Marshal(t.om)
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Items any `json:"items,omitempty"`
|
||||
@@ -264,12 +364,78 @@ func (pt PropertyType) String() string {
|
||||
return fmt.Sprintf("%v", []string(pt))
|
||||
}
|
||||
|
||||
// ToolPropertiesMap holds tool properties in insertion order.
|
||||
type ToolPropertiesMap struct {
|
||||
om *orderedmap.Map[string, ToolProperty]
|
||||
}
|
||||
|
||||
// NewToolPropertiesMap creates a new empty ToolPropertiesMap.
|
||||
func NewToolPropertiesMap() *ToolPropertiesMap {
|
||||
return &ToolPropertiesMap{om: orderedmap.New[string, ToolProperty]()}
|
||||
}
|
||||
|
||||
// Get retrieves a property by name.
|
||||
func (t *ToolPropertiesMap) Get(key string) (ToolProperty, bool) {
|
||||
if t == nil || t.om == nil {
|
||||
return ToolProperty{}, false
|
||||
}
|
||||
return t.om.Get(key)
|
||||
}
|
||||
|
||||
// Set sets a property, preserving insertion order.
|
||||
func (t *ToolPropertiesMap) Set(key string, value ToolProperty) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if t.om == nil {
|
||||
t.om = orderedmap.New[string, ToolProperty]()
|
||||
}
|
||||
t.om.Set(key, value)
|
||||
}
|
||||
|
||||
// Len returns the number of properties.
|
||||
func (t *ToolPropertiesMap) Len() int {
|
||||
if t == nil || t.om == nil {
|
||||
return 0
|
||||
}
|
||||
return t.om.Len()
|
||||
}
|
||||
|
||||
// All returns an iterator over all properties in insertion order.
|
||||
func (t *ToolPropertiesMap) All() iter.Seq2[string, ToolProperty] {
|
||||
if t == nil || t.om == nil {
|
||||
return func(yield func(string, ToolProperty) bool) {}
|
||||
}
|
||||
return t.om.All()
|
||||
}
|
||||
|
||||
// ToMap returns a regular map (order not preserved).
|
||||
func (t *ToolPropertiesMap) ToMap() map[string]ToolProperty {
|
||||
if t == nil || t.om == nil {
|
||||
return nil
|
||||
}
|
||||
return t.om.ToMap()
|
||||
}
|
||||
|
||||
func (t ToolPropertiesMap) MarshalJSON() ([]byte, error) {
|
||||
if t.om == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(t.om)
|
||||
}
|
||||
|
||||
func (t *ToolPropertiesMap) UnmarshalJSON(data []byte) error {
|
||||
t.om = orderedmap.New[string, ToolProperty]()
|
||||
return json.Unmarshal(data, t.om)
|
||||
}
|
||||
|
||||
type ToolProperty struct {
|
||||
AnyOf []ToolProperty `json:"anyOf,omitempty"`
|
||||
Type PropertyType `json:"type,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
AnyOf []ToolProperty `json:"anyOf,omitempty"`
|
||||
Type PropertyType `json:"type,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
||||
@@ -318,11 +484,11 @@ func mapToTypeScriptType(jsonType string) string {
|
||||
}
|
||||
|
||||
type ToolFunctionParameters struct {
|
||||
Type string `json:"type"`
|
||||
Defs any `json:"$defs,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]ToolProperty `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
Defs any `json:"$defs,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *ToolPropertiesMap `json:"properties"`
|
||||
}
|
||||
|
||||
func (t *ToolFunctionParameters) String() string {
|
||||
@@ -341,6 +507,27 @@ func (t *ToolFunction) String() string {
|
||||
return string(bts)
|
||||
}
|
||||
|
||||
// TokenLogprob represents log probability information for a single token alternative.
|
||||
type TokenLogprob struct {
|
||||
// Token is the text representation of the token.
|
||||
Token string `json:"token"`
|
||||
|
||||
// Logprob is the log probability of this token.
|
||||
Logprob float64 `json:"logprob"`
|
||||
|
||||
// Bytes contains the raw byte representation of the token
|
||||
Bytes []int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// Logprob contains log probability information for a generated token.
|
||||
type Logprob struct {
|
||||
TokenLogprob
|
||||
|
||||
// TopLogprobs contains the most likely tokens and their log probabilities
|
||||
// at this position, if requested via TopLogprobs parameter.
|
||||
TopLogprobs []TokenLogprob `json:"top_logprobs,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse is the response returned by [Client.Chat]. Its fields are
|
||||
// similar to [GenerateResponse].
|
||||
type ChatResponse struct {
|
||||
@@ -367,6 +554,10 @@ type ChatResponse struct {
|
||||
|
||||
DebugInfo *DebugInfo `json:"_debug_info,omitempty"`
|
||||
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
Metrics
|
||||
}
|
||||
|
||||
@@ -510,6 +701,9 @@ type CreateRequest struct {
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
|
||||
// Info is a map of additional information for the model
|
||||
Info map[string]any `json:"info,omitempty"`
|
||||
|
||||
@@ -555,11 +749,12 @@ type ShowResponse struct {
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModelInfo map[string]any `json:"model_info,omitempty"`
|
||||
ModelInfo map[string]any `json:"model_info"`
|
||||
ProjectorInfo map[string]any `json:"projector_info,omitempty"`
|
||||
Tensors []Tensor `json:"tensors,omitempty"`
|
||||
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at,omitempty"`
|
||||
Requires string `json:"requires,omitempty"`
|
||||
}
|
||||
|
||||
// CopyRequest is the request passed to [Client.Copy].
|
||||
@@ -639,6 +834,16 @@ type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type CloudStatus struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// StatusResponse is the response from [Client.CloudStatusExperimental].
|
||||
type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the response passed into [GenerateResponseFunc].
|
||||
type GenerateResponse struct {
|
||||
// Model is the model name that generated the response.
|
||||
@@ -675,6 +880,24 @@ type GenerateResponse struct {
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
|
||||
DebugInfo *DebugInfo `json:"_debug_info,omitempty"`
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -11,6 +11,24 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testPropsMap creates a ToolPropertiesMap from a map (convenience function for tests, order not preserved)
|
||||
func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
props := NewToolPropertiesMap()
|
||||
for k, v := range m {
|
||||
props.Set(k, v)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func TestKeepAliveParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -298,10 +316,48 @@ func TestToolFunction_UnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFunctionParameters_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input ToolFunctionParameters
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple object with string property",
|
||||
input: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {Type: PropertyType{"string"}},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`,
|
||||
},
|
||||
{
|
||||
name: "no required",
|
||||
input: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {Type: PropertyType{"string"}},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","properties":{"name":{"type":"string"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(test.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallFunction_IndexAlwaysMarshals(t *testing.T) {
|
||||
fn := ToolCallFunction{
|
||||
Name: "echo",
|
||||
Arguments: ToolCallFunctionArguments{"message": "hi"},
|
||||
Arguments: testArgs(map[string]any{"message": "hi"}),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(fn)
|
||||
@@ -466,6 +522,116 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPropertyNestedProperties(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected ToolProperty
|
||||
}{
|
||||
{
|
||||
name: "nested object properties",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"description": "Location details",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Street address"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expected: ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Location details",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"address": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "Street address",
|
||||
},
|
||||
"city": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "City name",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deeply nested properties",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"description": "Event",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "object",
|
||||
"description": "Location",
|
||||
"properties": {
|
||||
"coordinates": {
|
||||
"type": "object",
|
||||
"description": "GPS coordinates",
|
||||
"properties": {
|
||||
"lat": {"type": "number", "description": "Latitude"},
|
||||
"lng": {"type": "number", "description": "Longitude"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expected: ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Event",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"location": {
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Location",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"coordinates": {
|
||||
Type: PropertyType{"object"},
|
||||
Description: "GPS coordinates",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"lat": {Type: PropertyType{"number"}, Description: "Latitude"},
|
||||
"lng": {Type: PropertyType{"number"}, Description: "Longitude"},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var prop ToolProperty
|
||||
err := json.Unmarshal([]byte(tt.input), &prop)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare JSON representations since pointer comparison doesn't work
|
||||
expectedJSON, err := json.Marshal(tt.expected)
|
||||
require.NoError(t, err)
|
||||
actualJSON, err := json.Marshal(prop)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedJSON), string(actualJSON))
|
||||
|
||||
// Round-trip test: marshal and unmarshal again
|
||||
data, err := json.Marshal(prop)
|
||||
require.NoError(t, err)
|
||||
|
||||
var prop2 ToolProperty
|
||||
err = json.Unmarshal(data, &prop2)
|
||||
require.NoError(t, err)
|
||||
|
||||
prop2JSON, err := json.Marshal(prop2)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedJSON), string(prop2JSON))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFunctionParameters_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -477,12 +643,12 @@ func TestToolFunctionParameters_String(t *testing.T) {
|
||||
params: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: map[string]ToolProperty{
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "The name of the person",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"The name of the person"}}}`,
|
||||
},
|
||||
@@ -499,7 +665,7 @@ func TestToolFunctionParameters_String(t *testing.T) {
|
||||
s.Self = s
|
||||
return s
|
||||
}(),
|
||||
Properties: map[string]ToolProperty{},
|
||||
Properties: testPropsMap(map[string]ToolProperty{}),
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
@@ -512,3 +678,235 @@ func TestToolFunctionParameters_String(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallFunctionArguments_OrderPreservation(t *testing.T) {
|
||||
t.Run("marshal preserves insertion order", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("zebra", "z")
|
||||
args.Set("apple", "a")
|
||||
args.Set("mango", "m")
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should preserve insertion order, not alphabetical
|
||||
assert.Equal(t, `{"zebra":"z","apple":"a","mango":"m"}`, string(data))
|
||||
})
|
||||
|
||||
t.Run("unmarshal preserves JSON order", func(t *testing.T) {
|
||||
jsonData := `{"zebra":"z","apple":"a","mango":"m"}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(jsonData), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify iteration order matches JSON order
|
||||
var keys []string
|
||||
for k := range args.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"zebra", "apple", "mango"}, keys)
|
||||
})
|
||||
|
||||
t.Run("round trip preserves order", func(t *testing.T) {
|
||||
original := `{"z":1,"a":2,"m":3,"b":4}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(original), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original, string(data))
|
||||
})
|
||||
|
||||
t.Run("String method returns ordered JSON", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("c", 3)
|
||||
args.Set("a", 1)
|
||||
args.Set("b", 2)
|
||||
|
||||
assert.Equal(t, `{"c":3,"a":1,"b":2}`, args.String())
|
||||
})
|
||||
|
||||
t.Run("Get retrieves correct values", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("key1", "value1")
|
||||
args.Set("key2", 42)
|
||||
|
||||
v, ok := args.Get("key1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", v)
|
||||
|
||||
v, ok = args.Get("key2")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, v)
|
||||
|
||||
_, ok = args.Get("nonexistent")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("Len returns correct count", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
assert.Equal(t, 0, args.Len())
|
||||
|
||||
args.Set("a", 1)
|
||||
assert.Equal(t, 1, args.Len())
|
||||
|
||||
args.Set("b", 2)
|
||||
assert.Equal(t, 2, args.Len())
|
||||
})
|
||||
|
||||
t.Run("empty args marshal to empty object", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{}`, string(data))
|
||||
})
|
||||
|
||||
t.Run("zero value args marshal to empty object", func(t *testing.T) {
|
||||
var args ToolCallFunctionArguments
|
||||
assert.Equal(t, "{}", args.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolPropertiesMap_OrderPreservation(t *testing.T) {
|
||||
t.Run("marshal preserves insertion order", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("zebra", ToolProperty{Type: PropertyType{"string"}})
|
||||
props.Set("apple", ToolProperty{Type: PropertyType{"number"}})
|
||||
props.Set("mango", ToolProperty{Type: PropertyType{"boolean"}})
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should preserve insertion order, not alphabetical
|
||||
expected := `{"zebra":{"type":"string"},"apple":{"type":"number"},"mango":{"type":"boolean"}}`
|
||||
assert.Equal(t, expected, string(data))
|
||||
})
|
||||
|
||||
t.Run("unmarshal preserves JSON order", func(t *testing.T) {
|
||||
jsonData := `{"zebra":{"type":"string"},"apple":{"type":"number"},"mango":{"type":"boolean"}}`
|
||||
|
||||
var props ToolPropertiesMap
|
||||
err := json.Unmarshal([]byte(jsonData), &props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify iteration order matches JSON order
|
||||
var keys []string
|
||||
for k := range props.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"zebra", "apple", "mango"}, keys)
|
||||
})
|
||||
|
||||
t.Run("round trip preserves order", func(t *testing.T) {
|
||||
original := `{"z":{"type":"string"},"a":{"type":"number"},"m":{"type":"boolean"}}`
|
||||
|
||||
var props ToolPropertiesMap
|
||||
err := json.Unmarshal([]byte(original), &props)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original, string(data))
|
||||
})
|
||||
|
||||
t.Run("Get retrieves correct values", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("name", ToolProperty{Type: PropertyType{"string"}, Description: "The name"})
|
||||
props.Set("age", ToolProperty{Type: PropertyType{"integer"}, Description: "The age"})
|
||||
|
||||
v, ok := props.Get("name")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "The name", v.Description)
|
||||
|
||||
v, ok = props.Get("age")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "The age", v.Description)
|
||||
|
||||
_, ok = props.Get("nonexistent")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("Len returns correct count", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
assert.Equal(t, 0, props.Len())
|
||||
|
||||
props.Set("a", ToolProperty{})
|
||||
assert.Equal(t, 1, props.Len())
|
||||
|
||||
props.Set("b", ToolProperty{})
|
||||
assert.Equal(t, 2, props.Len())
|
||||
})
|
||||
|
||||
t.Run("nil props marshal to null", func(t *testing.T) {
|
||||
var props *ToolPropertiesMap
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `null`, string(data))
|
||||
})
|
||||
|
||||
t.Run("ToMap returns regular map", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("a", ToolProperty{Type: PropertyType{"string"}})
|
||||
props.Set("b", ToolProperty{Type: PropertyType{"number"}})
|
||||
|
||||
m := props.ToMap()
|
||||
assert.Equal(t, 2, len(m))
|
||||
assert.Equal(t, PropertyType{"string"}, m["a"].Type)
|
||||
assert.Equal(t, PropertyType{"number"}, m["b"].Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolCallFunctionArguments_ComplexValues(t *testing.T) {
|
||||
t.Run("nested objects preserve order", func(t *testing.T) {
|
||||
jsonData := `{"outer":{"z":1,"a":2},"simple":"value"}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(jsonData), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Outer keys should be in order
|
||||
var keys []string
|
||||
for k := range args.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"outer", "simple"}, keys)
|
||||
})
|
||||
|
||||
t.Run("arrays as values", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("items", []string{"a", "b", "c"})
|
||||
args.Set("numbers", []int{1, 2, 3})
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `{"items":["a","b","c"],"numbers":[1,2,3]}`, string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolPropertiesMap_NestedProperties(t *testing.T) {
|
||||
t.Run("nested properties preserve order", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
|
||||
nestedProps := NewToolPropertiesMap()
|
||||
nestedProps.Set("z_field", ToolProperty{Type: PropertyType{"string"}})
|
||||
nestedProps.Set("a_field", ToolProperty{Type: PropertyType{"number"}})
|
||||
|
||||
props.Set("outer", ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Properties: nestedProps,
|
||||
})
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both outer and inner should preserve order
|
||||
expected := `{"outer":{"type":"object","properties":{"z_field":{"type":"string"},"a_field":{"type":"number"}}}}`
|
||||
assert.Equal(t, expected, string(data))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,22 +1,97 @@
|
||||
# Ollama App
|
||||
# Ollama for macOS and Windows
|
||||
|
||||
## Linux
|
||||
## Download
|
||||
|
||||
TODO
|
||||
- [macOS](https://github.com/ollama/app/releases/download/latest/Ollama.dmg)
|
||||
- [Windows](https://github.com/ollama/app/releases/download/latest/OllamaSetup.exe)
|
||||
|
||||
## MacOS
|
||||
## Development
|
||||
|
||||
TODO
|
||||
### Desktop App
|
||||
|
||||
## Windows
|
||||
```bash
|
||||
go generate ./... &&
|
||||
go run ./cmd/app
|
||||
```
|
||||
|
||||
### UI Development
|
||||
|
||||
#### Setup
|
||||
|
||||
Install required tools:
|
||||
|
||||
```bash
|
||||
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
|
||||
```
|
||||
|
||||
#### Develop UI (Development Mode)
|
||||
|
||||
1. Start the React development server (with hot-reload):
|
||||
|
||||
```bash
|
||||
cd ui/app
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. In a separate terminal, run the Ollama app with the `-dev` flag:
|
||||
|
||||
```bash
|
||||
go generate ./... &&
|
||||
OLLAMA_DEBUG=1 go run ./cmd/app -dev
|
||||
```
|
||||
|
||||
The `-dev` flag enables:
|
||||
|
||||
- Loading the UI from the Vite dev server at http://localhost:5173
|
||||
- Fixed UI server port at http://127.0.0.1:3001 for API requests
|
||||
- CORS headers for cross-origin requests
|
||||
- Hot-reload support for UI development
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
### Windows
|
||||
|
||||
If you want to build the installer, youll need to install
|
||||
- https://jrsoftware.org/isinfo.php
|
||||
|
||||
|
||||
In the top directory of this repo, run the following powershell script
|
||||
to build the ollama CLI, ollama app, and ollama installer.
|
||||
|
||||
**Dependencies** - either build a local copy of ollama, or use a github release
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows.ps1
|
||||
# Local dependencies
|
||||
.\scripts\deps_local.ps1
|
||||
|
||||
# Release dependencies
|
||||
.\scripts\deps_release.ps1 0.6.8
|
||||
```
|
||||
|
||||
**Build**
|
||||
```powershell
|
||||
.\scripts\build_windows.ps1
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
CI builds with Xcode 14.1 for OS compatibility prior to v13. If you want to manually build v11+ support, you can download the older Xcode [here](https://developer.apple.com/services-account/download?path=/Developer_Tools/Xcode_14.1/Xcode_14.1.xip), extract, then `mv ./Xcode.app /Applications/Xcode_14.1.0.app` then activate with:
|
||||
|
||||
```
|
||||
export CGO_CFLAGS="-O3 -mmacosx-version-min=12.0"
|
||||
export CGO_CXXFLAGS="-O3 -mmacosx-version-min=12.0"
|
||||
export CGO_LDFLAGS="-mmacosx-version-min=12.0"
|
||||
export SDKROOT=/Applications/Xcode_14.1.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
|
||||
export DEVELOPER_DIR=/Applications/Xcode_14.1.0.app/Contents/Developer
|
||||
```
|
||||
|
||||
**Dependencies** - either build a local copy of Ollama, or use a GitHub release:
|
||||
```sh
|
||||
# Local dependencies
|
||||
./scripts/deps_local.sh
|
||||
|
||||
# Release dependencies
|
||||
./scripts/deps_release.sh 0.6.8
|
||||
```
|
||||
|
||||
**Build**
|
||||
```sh
|
||||
./scripts/build_darwin.sh
|
||||
```
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -22,7 +24,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/ollama/ollama/app/auth"
|
||||
"github.com/ollama/ollama/app/logrotate"
|
||||
"github.com/ollama/ollama/app/network"
|
||||
"github.com/ollama/ollama/app/server"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/tools"
|
||||
@@ -31,13 +32,18 @@ import (
|
||||
"github.com/ollama/ollama/app/version"
|
||||
)
|
||||
|
||||
var wv = &Webview{}
|
||||
var uiServerPort int
|
||||
var (
|
||||
wv = &Webview{}
|
||||
uiServerPort int
|
||||
appStore *store.Store
|
||||
)
|
||||
|
||||
var debug = strings.EqualFold(os.Getenv("OLLAMA_DEBUG"), "true") || os.Getenv("OLLAMA_DEBUG") == "1"
|
||||
|
||||
var fastStartup = false
|
||||
var devMode = false
|
||||
var (
|
||||
fastStartup = false
|
||||
devMode = false
|
||||
)
|
||||
|
||||
type appMove int
|
||||
|
||||
@@ -70,7 +76,7 @@ func main() {
|
||||
fmt.Println(version.Version)
|
||||
os.Exit(0)
|
||||
case "background":
|
||||
// When running the process in this "backgroud" mode, we spawn a
|
||||
// When running the process in this "background" mode, we spawn a
|
||||
// child process for the main app. This is necessary so the
|
||||
// "Allow in the Background" setting in MacOS can be unchecked
|
||||
// without breaking the main app. Two copies of the app are
|
||||
@@ -102,7 +108,7 @@ func main() {
|
||||
|
||||
logrotate.Rotate(appLogPath)
|
||||
if _, err := os.Stat(filepath.Dir(appLogPath)); errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.MkdirAll(filepath.Dir(appLogPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(appLogPath), 0o755); err != nil {
|
||||
slog.Error(fmt.Sprintf("failed to create server log dir %v", err))
|
||||
return
|
||||
}
|
||||
@@ -178,7 +184,7 @@ func main() {
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance()
|
||||
handleExistingInstance(startHidden)
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
@@ -203,6 +209,7 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
if devMode {
|
||||
@@ -248,6 +255,8 @@ func main() {
|
||||
done <- osrv.Run(octx)
|
||||
}()
|
||||
|
||||
upd := &updater.Updater{Store: st}
|
||||
|
||||
uiServer := ui.Server{
|
||||
Token: token,
|
||||
Restart: func() {
|
||||
@@ -258,23 +267,20 @@ func main() {
|
||||
done <- osrv.Run(octx)
|
||||
}()
|
||||
},
|
||||
Store: st,
|
||||
ToolRegistry: toolRegistry,
|
||||
Dev: devMode,
|
||||
Logger: slog.Default(),
|
||||
NetworkMonitor: network.NewMonitor(),
|
||||
Store: st,
|
||||
ToolRegistry: toolRegistry,
|
||||
Dev: devMode,
|
||||
Logger: slog.Default(),
|
||||
Updater: upd,
|
||||
UpdateAvailableFunc: func() {
|
||||
UpdateAvailable("")
|
||||
},
|
||||
}
|
||||
|
||||
uiServer.NetworkMonitor.Start(ctx)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: uiServer.Handler(),
|
||||
}
|
||||
|
||||
if _, err := uiServer.UserData(ctx); err != nil {
|
||||
slog.Warn("failed to load user data", "error", err)
|
||||
}
|
||||
|
||||
// Start the UI server
|
||||
slog.Info("starting ui server", "port", port)
|
||||
go func() {
|
||||
@@ -286,8 +292,20 @@ func main() {
|
||||
slog.Debug("background desktop server done")
|
||||
}()
|
||||
|
||||
updater := &updater.Updater{Store: st}
|
||||
updater.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
|
||||
upd.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
|
||||
|
||||
// Check for pending updates on startup (show tray notification if update is ready)
|
||||
if updater.IsUpdatePending() {
|
||||
// On Windows, the tray is initialized in osRun(). Calling UpdateAvailable
|
||||
// before that would dereference a nil tray callback.
|
||||
// TODO: refactor so the update check runs after platform init on all platforms.
|
||||
if runtime.GOOS == "windows" {
|
||||
slog.Debug("update pending on startup, deferring tray notification until tray initialization")
|
||||
} else {
|
||||
slog.Debug("update pending on startup, showing tray notification")
|
||||
UpdateAvailable("")
|
||||
}
|
||||
}
|
||||
|
||||
hasCompletedFirstRun, err := st.HasCompletedFirstRun()
|
||||
if err != nil {
|
||||
@@ -318,6 +336,17 @@ func main() {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Debug("waiting for ollama server to be ready")
|
||||
if err := ui.WaitForServer(ctx, 10*time.Second); err != nil {
|
||||
slog.Warn("ollama server not ready, continuing anyway", "error", err)
|
||||
}
|
||||
|
||||
if _, err := uiServer.UserData(ctx); err != nil {
|
||||
slog.Warn("failed to load user data", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
@@ -339,6 +368,17 @@ func startHiddenTasks() {
|
||||
// CLI triggered app startup use-case
|
||||
slog.Info("deferring pending update for fast startup")
|
||||
} else {
|
||||
// Check if auto-update is enabled before automatically upgrading
|
||||
settings, err := appStore.Settings()
|
||||
if err != nil {
|
||||
slog.Warn("failed to load settings for upgrade check", "error", err)
|
||||
} else if !settings.AutoUpdateEnabled {
|
||||
slog.Info("auto-update disabled, skipping automatic upgrade at startup")
|
||||
// Still show tray notification so user knows update is ready
|
||||
UpdateAvailable("")
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil {
|
||||
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
|
||||
@@ -359,7 +399,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/me", uiServerPort))
|
||||
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/me", uiServerPort), "application/json", nil)
|
||||
if err != nil {
|
||||
slog.Debug("failed to call local auth endpoint", "error", err)
|
||||
return false
|
||||
@@ -395,8 +435,8 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
// handleConnectURLScheme fetches the connect URL and opens it in the browser
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening settings instead")
|
||||
sendUIRequestMessage("/")
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -432,37 +472,30 @@ func openInBrowser(url string) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseURLScheme parses an ollama:// URL and returns whether it's a connect URL and the UI path
|
||||
func parseURLScheme(urlSchemeRequest string) (isConnect bool, uiPath string, err error) {
|
||||
// parseURLScheme parses an ollama:// URL and validates it
|
||||
// Supports: ollama:// (open app) and ollama://connect (OAuth)
|
||||
func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
parsedURL, err := url.Parse(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
return false, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is a connect URL
|
||||
if parsedURL.Host == "connect" || strings.TrimPrefix(parsedURL.Path, "/") == "connect" {
|
||||
return true, "", nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Extract the UI path
|
||||
path := "/"
|
||||
if parsedURL.Path != "" && parsedURL.Path != "/" {
|
||||
// For URLs like ollama:///settings, use the path directly
|
||||
path = parsedURL.Path
|
||||
} else if parsedURL.Host != "" {
|
||||
// For URLs like ollama://settings (without triple slash),
|
||||
// the "settings" part is parsed as the host, not the path.
|
||||
// We need to convert it to a path by prepending "/"
|
||||
// This also handles ollama://settings/ where Windows adds a trailing slash
|
||||
path = "/" + parsedURL.Host
|
||||
// Allow bare ollama:// or ollama:/// to open the app
|
||||
if (parsedURL.Host == "" && parsedURL.Path == "") || parsedURL.Path == "/" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, path, nil
|
||||
return false, fmt.Errorf("unsupported ollama:// URL path: %s", urlSchemeRequest)
|
||||
}
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, uiPath, err := parseURLScheme(urlSchemeRequest)
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
@@ -471,6 +504,8 @@ func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
sendUIRequestMessage(uiPath)
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
// #cgo CFLAGS: -x objective-c
|
||||
@@ -6,6 +8,7 @@ package main
|
||||
// #include "../../updater/updater_darwin.h"
|
||||
// typedef const char cchar_t;
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -32,9 +35,11 @@ var ollamaPath = func() string {
|
||||
return filepath.Join(pwd, "ollama")
|
||||
}()
|
||||
|
||||
var isApp = updater.BundlePath != ""
|
||||
var appLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "app.log")
|
||||
var launchAgentPath = filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "com.ollama.ollama.plist")
|
||||
var (
|
||||
isApp = updater.BundlePath != ""
|
||||
appLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "app.log")
|
||||
launchAgentPath = filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "com.ollama.ollama.plist")
|
||||
)
|
||||
|
||||
// TODO(jmorganca): pre-create the window and pass
|
||||
// it to the webview instead of using the internal one
|
||||
@@ -123,7 +128,7 @@ func maybeMoveAndRestart() appMove {
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS
|
||||
func handleExistingInstance() {
|
||||
func handleExistingInstance(_ bool) {
|
||||
C.killOtherInstances()
|
||||
}
|
||||
|
||||
@@ -186,13 +191,6 @@ func LaunchNewApp() {
|
||||
C.launchApp(appName)
|
||||
}
|
||||
|
||||
// Send a request to the main app thread to load a UI page
|
||||
func sendUIRequestMessage(path string) {
|
||||
p := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(p))
|
||||
C.uiRequest(p)
|
||||
}
|
||||
|
||||
func registerLaunchAgent(hasCompletedFirstRun bool) {
|
||||
// Remove any stale Login Item registrations
|
||||
C.unregisterSelfFromLoginItem()
|
||||
|
||||
@@ -14,6 +14,7 @@ extern NSString *SystemWidePath;
|
||||
@interface AppDelegate () <NSWindowDelegate, WKNavigationDelegate, WKUIDelegate>
|
||||
@property(strong, nonatomic) NSStatusItem *statusItem;
|
||||
@property(assign, nonatomic) BOOL updateAvailable;
|
||||
@property(assign, nonatomic) BOOL systemShutdownInProgress;
|
||||
@end
|
||||
|
||||
@implementation AppDelegate
|
||||
@@ -24,27 +25,14 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
for (NSURL *url in urls) {
|
||||
if ([url.scheme isEqualToString:@"ollama"]) {
|
||||
NSString *path = url.path;
|
||||
if (!path || [path isEqualToString:@""]) {
|
||||
// For URLs like ollama://settings (without triple slash),
|
||||
// the "settings" part is parsed as the host, not the path.
|
||||
// We need to convert it to a path by prepending "/"
|
||||
if (url.host && ![url.host isEqualToString:@""]) {
|
||||
path = [@"/" stringByAppendingString:url.host];
|
||||
} else {
|
||||
path = @"/";
|
||||
}
|
||||
}
|
||||
|
||||
if ([path isEqualToString:@"/connect"] || [url.host isEqualToString:@"connect"]) {
|
||||
|
||||
if (path && ([path isEqualToString:@"/connect"] || [url.host isEqualToString:@"connect"])) {
|
||||
// Special case: handle connect by opening browser instead of app
|
||||
handleConnectURL();
|
||||
} else {
|
||||
// Set app to be active and visible
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
|
||||
// Open the path with the UI
|
||||
[self uiRequest:path];
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -53,6 +41,13 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
}
|
||||
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
|
||||
// Register for system shutdown/restart notification so we can allow termination
|
||||
[[[NSWorkspace sharedWorkspace] notificationCenter]
|
||||
addObserver:self
|
||||
selector:@selector(systemWillPowerOff:)
|
||||
name:NSWorkspaceWillPowerOffNotification
|
||||
object:nil];
|
||||
|
||||
// if we're in development mode, set the app icon
|
||||
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
|
||||
if (![bundlePath hasSuffix:@".app"]) {
|
||||
@@ -260,7 +255,7 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
}
|
||||
|
||||
- (void)openHelp:(id)sender {
|
||||
NSURL *url = [NSURL URLWithString:@"https://github.com/ollama/ollama/tree/main/docs"];
|
||||
NSURL *url = [NSURL URLWithString:@"https://docs.ollama.com/"];
|
||||
[[NSWorkspace sharedWorkspace] openURL:url];
|
||||
}
|
||||
|
||||
@@ -291,7 +286,18 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
}
|
||||
|
||||
- (void)systemWillPowerOff:(NSNotification *)notification {
|
||||
// Set flag so applicationShouldTerminate: knows to allow termination.
|
||||
// The system will call applicationShouldTerminate: after posting this notification.
|
||||
self.systemShutdownInProgress = YES;
|
||||
}
|
||||
|
||||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
|
||||
// Allow termination if the system is shutting down or restarting
|
||||
if (self.systemShutdownInProgress) {
|
||||
return NSTerminateNow;
|
||||
}
|
||||
// Otherwise just hide the app (for Cmd+Q, close button, etc.)
|
||||
[NSApp hide:nil];
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
return NSTerminateCancel;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -24,7 +26,6 @@ import (
|
||||
var (
|
||||
u32 = windows.NewLazySystemDLL("User32.dll")
|
||||
pBringWindowToTop = u32.NewProc("BringWindowToTop")
|
||||
pSetWindowLong = u32.NewProc("SetWindowLongA")
|
||||
pShowWindow = u32.NewProc("ShowWindow")
|
||||
pSendMessage = u32.NewProc("SendMessageA")
|
||||
pGetSystemMetrics = u32.NewProc("GetSystemMetrics")
|
||||
@@ -35,7 +36,6 @@ var (
|
||||
pIsIconic = u32.NewProc("IsIconic")
|
||||
|
||||
appPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Ollama")
|
||||
appDataPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama")
|
||||
appLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "app.log")
|
||||
startupShortcut = filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "Ollama.lnk")
|
||||
ollamaPath string
|
||||
@@ -73,10 +73,10 @@ func maybeMoveAndRestart() appMove {
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and focuses them
|
||||
func handleExistingInstance() {
|
||||
if wintray.CheckAndFocusExistingInstance() {
|
||||
slog.Info("existing instance found and focused, exiting")
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ var app = &appCallbacks{}
|
||||
func (ac *appCallbacks) UIRun(path string) {
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
@@ -100,18 +101,21 @@ func (*appCallbacks) UIShow() {
|
||||
wv.Run("/")
|
||||
}
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
// TODO - reconcile with above for consitency between mac/windows
|
||||
// TODO - reconcile with above for consistency between mac/windows
|
||||
func quit() {
|
||||
wv.Terminate()
|
||||
}
|
||||
@@ -134,7 +138,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
isConnect, uiPath, err := parseURLScheme(urlScheme)
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
@@ -143,11 +147,17 @@ func handleURLSchemeRequest(urlScheme string) {
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
sendUIRequestMessage(uiPath)
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
if app.t == nil {
|
||||
slog.Debug("tray not yet initialized, skipping update notification")
|
||||
return nil
|
||||
}
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
@@ -159,6 +169,14 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
log.Fatalf("Failed to start: %s", err)
|
||||
}
|
||||
|
||||
// Check for pending updates now that the tray is initialized.
|
||||
// The platform-independent check in app.go fires before osRun,
|
||||
// when app.t is still nil, so we must re-check here.
|
||||
if updater.IsUpdatePending() {
|
||||
slog.Debug("update pending on startup, showing tray notification")
|
||||
UpdateAvailable("")
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -190,25 +208,23 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
if !startHidden {
|
||||
ptr := wv.Run("/")
|
||||
ptr := wv.Run("/")
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
iconHandle := app.t.GetIconHandle()
|
||||
if iconHandle != 0 {
|
||||
hwnd := uintptr(ptr)
|
||||
const ICON_SMALL = 0
|
||||
const ICON_BIG = 1
|
||||
const WM_SETICON = 0x0080
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
iconHandle := app.t.GetIconHandle()
|
||||
if iconHandle != 0 {
|
||||
hwnd := uintptr(ptr)
|
||||
const ICON_SMALL = 0
|
||||
const ICON_BIG = 1
|
||||
const WM_SETICON = 0x0080
|
||||
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
|
||||
}
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
@@ -259,13 +275,7 @@ func createLoginShortcut() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send a request to the main app thread to load a UI page
|
||||
func sendUIRequestMessage(path string) {
|
||||
wintray.SendUIRequestMessage(path)
|
||||
}
|
||||
|
||||
func LaunchNewApp() {
|
||||
|
||||
}
|
||||
|
||||
func logStartup() {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
// #include "menu.h"
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -17,8 +22,6 @@ import (
|
||||
"github.com/ollama/ollama/app/dialog"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
@@ -261,42 +264,96 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
})
|
||||
|
||||
// Bind selectFile function for React UI
|
||||
// Uses callback pattern since webview bindings can't directly return Promises
|
||||
// The HTML wrapper creates a Promise that resolves when this callback is called
|
||||
wv.Bind("selectFile", func() {
|
||||
// Bind selectFiles function for selecting multiple files at once
|
||||
wv.Bind("selectFiles", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectFileCallback && window.__selectFileCallback(%s)", dataJSON))
|
||||
wv.Eval(fmt.Sprintf("window.__selectFilesCallback && window.__selectFilesCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
filename, err := dialog.File().Load()
|
||||
// Define allowed extensions for native dialog filtering
|
||||
textExts := []string{
|
||||
"pdf", "docx", "txt", "md", "csv", "json", "xml", "html", "htm",
|
||||
"js", "jsx", "ts", "tsx", "py", "java", "cpp", "c", "cc", "h", "cs", "php", "rb",
|
||||
"go", "rs", "swift", "kt", "scala", "sh", "bat", "yaml", "yml", "toml", "ini",
|
||||
"cfg", "conf", "log", "rtf",
|
||||
}
|
||||
imageExts := []string{"png", "jpg", "jpeg", "webp"}
|
||||
allowedExts := append(textExts, imageExts...)
|
||||
|
||||
// Use native multiple file selection with extension filtering
|
||||
filenames, err := dialog.File().
|
||||
Filter("Supported Files", allowedExts...).
|
||||
Title("Select Files").
|
||||
LoadMultiple()
|
||||
if err != nil {
|
||||
slog.Debug("Multiple file selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
fileData, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to read file", "error", err)
|
||||
if len(filenames) == 0 {
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(fileData)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileData))
|
||||
var files []map[string]string
|
||||
maxFileSize := int64(10 * 1024 * 1024) // 10MB
|
||||
|
||||
data := map[string]string{
|
||||
"filename": filepath.Base(filename),
|
||||
"path": filename,
|
||||
"dataURL": dataURL,
|
||||
for _, filename := range filenames {
|
||||
// Check file extension (double-check after native dialog filtering)
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
validExt := false
|
||||
for _, allowedExt := range allowedExts {
|
||||
if ext == allowedExt {
|
||||
validExt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validExt {
|
||||
slog.Warn("file extension not allowed, skipping", "filename", filepath.Base(filename), "extension", ext)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size before reading (pre-filter large files)
|
||||
fileStat, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to get file info", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
if fileStat.Size() > maxFileSize {
|
||||
slog.Warn("file too large, skipping", "filename", filepath.Base(filename), "size", fileStat.Size())
|
||||
continue
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to read file", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(fileBytes)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileBytes))
|
||||
|
||||
fileResult := map[string]string{
|
||||
"filename": filepath.Base(filename),
|
||||
"path": filename,
|
||||
"dataURL": dataURL,
|
||||
}
|
||||
|
||||
files = append(files, fileResult)
|
||||
}
|
||||
|
||||
callCallback(data)
|
||||
if len(files) == 0 {
|
||||
callCallback(nil)
|
||||
} else {
|
||||
callCallback(files)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
@@ -438,9 +495,11 @@ func (w *Webview) IsRunning() bool {
|
||||
return w.webview != nil
|
||||
}
|
||||
|
||||
var menuItems []C.menuItem
|
||||
var menuMutex sync.RWMutex
|
||||
var pinner runtime.Pinner
|
||||
var (
|
||||
menuItems []C.menuItem
|
||||
menuMutex sync.RWMutex
|
||||
pinner runtime.Pinner
|
||||
)
|
||||
|
||||
//export menu_get_item_count
|
||||
func menu_get_item_count() C.int {
|
||||
|
||||
@@ -27,6 +27,7 @@ typedef struct {
|
||||
char* startDir; /* directory to start in (can be nil) */
|
||||
char* filename; /* default filename for dialog box (can be nil) */
|
||||
int showHidden; /* show hidden files? */
|
||||
int allowMultiple; /* allow multiple file selection? */
|
||||
} FileDlgParams;
|
||||
|
||||
typedef enum {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include "dlg.h"
|
||||
#include <string.h>
|
||||
#include <sys/syslimits.h>
|
||||
|
||||
// Import UniformTypeIdentifiers for macOS 11+
|
||||
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
|
||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||
#endif
|
||||
|
||||
void* NSStr(void* buf, int len) {
|
||||
return (void*)[[NSString alloc] initWithBytes:buf length:len encoding:NSUTF8StringEncoding];
|
||||
@@ -107,12 +114,20 @@ DlgResult fileDlg(FileDlgParams* params) {
|
||||
if(self->params->title != nil) {
|
||||
[panel setTitle:[[NSString alloc] initWithUTF8String:self->params->title]];
|
||||
}
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
// Use modern allowedContentTypes API for better file type support (especially video files)
|
||||
if(self->params->numext > 0) {
|
||||
[panel setAllowedFileTypes:[NSArray arrayWithObjects:(NSString**)self->params->exts count:self->params->numext]];
|
||||
NSMutableArray *utTypes = [NSMutableArray arrayWithCapacity:self->params->numext];
|
||||
NSString** exts = (NSString**)self->params->exts;
|
||||
for(int i = 0; i < self->params->numext; i++) {
|
||||
UTType *type = [UTType typeWithFilenameExtension:exts[i]];
|
||||
if(type) {
|
||||
[utTypes addObject:type];
|
||||
}
|
||||
}
|
||||
if([utTypes count] > 0) {
|
||||
[panel setAllowedContentTypes:utTypes];
|
||||
}
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
if(self->params->relaxext) {
|
||||
[panel setAllowsOtherFileTypes:YES];
|
||||
}
|
||||
@@ -144,13 +159,59 @@ DlgResult fileDlg(FileDlgParams* params) {
|
||||
[panel setCanChooseDirectories:YES];
|
||||
[panel setCanChooseFiles:NO];
|
||||
}
|
||||
|
||||
if(self->params->allowMultiple) {
|
||||
[panel setAllowsMultipleSelection:YES];
|
||||
}
|
||||
|
||||
if(![self runPanel:panel]) {
|
||||
return DLG_CANCEL;
|
||||
}
|
||||
NSURL* url = [[panel URLs] objectAtIndex:0];
|
||||
if(![url getFileSystemRepresentation:self->params->buf maxLength:self->params->nbuf]) {
|
||||
return DLG_URLFAIL;
|
||||
|
||||
NSArray* urls = [panel URLs];
|
||||
if([urls count] == 0) {
|
||||
return DLG_CANCEL;
|
||||
}
|
||||
|
||||
if(self->params->allowMultiple) {
|
||||
// For multiple files, we need to return all paths separated by null bytes
|
||||
char* bufPtr = self->params->buf;
|
||||
int remainingBuf = self->params->nbuf;
|
||||
|
||||
// Calculate total required buffer size first
|
||||
int totalSize = 0;
|
||||
for(NSURL* url in urls) {
|
||||
char tempBuf[PATH_MAX];
|
||||
if(![url getFileSystemRepresentation:tempBuf maxLength:PATH_MAX]) {
|
||||
return DLG_URLFAIL;
|
||||
}
|
||||
totalSize += strlen(tempBuf) + 1; // +1 for null terminator
|
||||
}
|
||||
totalSize += 1; // Final null terminator
|
||||
|
||||
if(totalSize > self->params->nbuf) {
|
||||
// Not enough buffer space
|
||||
return DLG_URLFAIL;
|
||||
}
|
||||
|
||||
// Now actually copy the paths (we know we have space)
|
||||
bufPtr = self->params->buf;
|
||||
for(NSURL* url in urls) {
|
||||
char tempBuf[PATH_MAX];
|
||||
[url getFileSystemRepresentation:tempBuf maxLength:PATH_MAX];
|
||||
int pathLen = strlen(tempBuf);
|
||||
strcpy(bufPtr, tempBuf);
|
||||
bufPtr += pathLen + 1;
|
||||
}
|
||||
*bufPtr = '\0'; // Final null terminator
|
||||
} else {
|
||||
// Single file/directory selection - write path to buffer
|
||||
NSURL* url = [urls firstObject];
|
||||
if(![url getFileSystemRepresentation:self->params->buf maxLength:self->params->nbuf]) {
|
||||
return DLG_URLFAIL;
|
||||
}
|
||||
}
|
||||
|
||||
return DLG_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package cocoa
|
||||
|
||||
// #cgo darwin LDFLAGS: -framework Cocoa
|
||||
// #cgo darwin LDFLAGS: -framework Cocoa -framework UniformTypeIdentifiers
|
||||
// #include <stdlib.h>
|
||||
// #include <sys/syslimits.h>
|
||||
// #include "dlg.h"
|
||||
@@ -57,31 +57,67 @@ func ErrorDlg(msg, title string) {
|
||||
a.run()
|
||||
}
|
||||
|
||||
const BUFSIZE = C.PATH_MAX
|
||||
const (
|
||||
BUFSIZE = C.PATH_MAX
|
||||
MULTI_FILE_BUF_SIZE = 32768
|
||||
)
|
||||
|
||||
// MultiFileDlg opens a file dialog that allows multiple file selection
|
||||
func MultiFileDlg(title string, exts []string, relaxExt bool, startDir string, showHidden bool) ([]string, error) {
|
||||
return fileDlgWithOptions(C.LOADDLG, title, exts, relaxExt, startDir, "", showHidden, true)
|
||||
}
|
||||
|
||||
// FileDlg opens a file dialog for single file selection (kept for compatibility)
|
||||
func FileDlg(save bool, title string, exts []string, relaxExt bool, startDir string, filename string, showHidden bool) (string, error) {
|
||||
mode := C.LOADDLG
|
||||
if save {
|
||||
mode = C.SAVEDLG
|
||||
}
|
||||
return fileDlg(mode, title, exts, relaxExt, startDir, filename, showHidden)
|
||||
files, err := fileDlgWithOptions(mode, title, exts, relaxExt, startDir, filename, showHidden, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return files[0], nil
|
||||
}
|
||||
|
||||
func DirDlg(title string, startDir string, showHidden bool) (string, error) {
|
||||
return fileDlg(C.DIRDLG, title, nil, false, startDir, "", showHidden)
|
||||
files, err := fileDlgWithOptions(C.DIRDLG, title, nil, false, startDir, "", showHidden, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return files[0], nil
|
||||
}
|
||||
|
||||
func fileDlg(mode int, title string, exts []string, relaxExt bool, startDir, filename string, showHidden bool) (string, error) {
|
||||
// fileDlgWithOptions is the unified file dialog function that handles both single and multiple selection
|
||||
func fileDlgWithOptions(mode int, title string, exts []string, relaxExt bool, startDir, filename string, showHidden, allowMultiple bool) ([]string, error) {
|
||||
// Use larger buffer for multiple files, smaller for single
|
||||
bufSize := BUFSIZE
|
||||
if allowMultiple {
|
||||
bufSize = MULTI_FILE_BUF_SIZE
|
||||
}
|
||||
|
||||
p := C.FileDlgParams{
|
||||
mode: C.int(mode),
|
||||
nbuf: BUFSIZE,
|
||||
nbuf: C.int(bufSize),
|
||||
}
|
||||
|
||||
if allowMultiple {
|
||||
p.allowMultiple = C.int(1) // Enable multiple selection //nolint:structcheck
|
||||
}
|
||||
if showHidden {
|
||||
p.showHidden = 1
|
||||
}
|
||||
p.buf = (*C.char)(C.malloc(BUFSIZE))
|
||||
|
||||
p.buf = (*C.char)(C.malloc(C.size_t(bufSize)))
|
||||
defer C.free(unsafe.Pointer(p.buf))
|
||||
buf := (*(*[BUFSIZE]byte)(unsafe.Pointer(p.buf)))[:]
|
||||
buf := (*(*[MULTI_FILE_BUF_SIZE]byte)(unsafe.Pointer(p.buf)))[:bufSize]
|
||||
|
||||
if title != "" {
|
||||
p.title = C.CString(title)
|
||||
defer C.free(unsafe.Pointer(p.title))
|
||||
@@ -94,6 +130,7 @@ func fileDlg(mode int, title string, exts []string, relaxExt bool, startDir, fil
|
||||
p.filename = C.CString(filename)
|
||||
defer C.free(unsafe.Pointer(p.filename))
|
||||
}
|
||||
|
||||
if len(exts) > 0 {
|
||||
if len(exts) > 999 {
|
||||
panic("more than 999 extensions not supported")
|
||||
@@ -103,7 +140,6 @@ func fileDlg(mode int, title string, exts []string, relaxExt bool, startDir, fil
|
||||
defer C.free(unsafe.Pointer(p.exts))
|
||||
cext := (*(*[999]unsafe.Pointer)(unsafe.Pointer(p.exts)))[:]
|
||||
for i, ext := range exts {
|
||||
i := i
|
||||
cext[i] = nsStr(ext)
|
||||
defer C.NSRelease(cext[i])
|
||||
}
|
||||
@@ -112,14 +148,36 @@ func fileDlg(mode int, title string, exts []string, relaxExt bool, startDir, fil
|
||||
p.relaxext = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Execute dialog and parse results
|
||||
switch C.fileDlg(&p) {
|
||||
case C.DLG_OK:
|
||||
// casting to string copies the [about-to-be-freed] bytes
|
||||
return string(buf[:bytes.Index(buf, []byte{0})]), nil
|
||||
if allowMultiple {
|
||||
// Parse multiple null-terminated strings from buffer
|
||||
var files []string
|
||||
start := 0
|
||||
for i := range len(buf) - 1 {
|
||||
if buf[i] == 0 {
|
||||
if i > start {
|
||||
files = append(files, string(buf[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
// Check for double null (end of list)
|
||||
if i+1 < len(buf) && buf[i+1] == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
} else {
|
||||
// Single file - return as array for consistency
|
||||
filename := string(buf[:bytes.Index(buf, []byte{0})])
|
||||
return []string{filename}, nil
|
||||
}
|
||||
case C.DLG_CANCEL:
|
||||
return "", nil
|
||||
return nil, nil
|
||||
case C.DLG_URLFAIL:
|
||||
return "", errors.New("failed to get file-system representation for selected URL")
|
||||
return nil, errors.New("failed to get file-system representation for selected URL")
|
||||
}
|
||||
panic("unhandled case")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
// Package dialog provides a simple cross-platform common dialog API.
|
||||
// Eg. to prompt the user with a yes/no dialog:
|
||||
//
|
||||
// if dialog.MsgDlg("%s", "Do you want to continue?").YesNo() {
|
||||
// // user pressed Yes
|
||||
// }
|
||||
// if dialog.MsgDlg("%s", "Do you want to continue?").YesNo() {
|
||||
// // user pressed Yes
|
||||
// }
|
||||
//
|
||||
// The general usage pattern is to call one of the toplevel *Dlg functions
|
||||
// which return a *Builder structure. From here you can optionally call
|
||||
@@ -126,6 +128,13 @@ func (b *FileBuilder) Load() (string, error) {
|
||||
return b.load()
|
||||
}
|
||||
|
||||
// LoadMultiple spawns the file selection dialog using the configured settings,
|
||||
// asking the user to select multiple files. Returns ErrCancelled as the error
|
||||
// if the user cancels or closes the dialog.
|
||||
func (b *FileBuilder) LoadMultiple() ([]string, error) {
|
||||
return b.loadMultiple()
|
||||
}
|
||||
|
||||
// Save spawns the file selection dialog using the configured settings,
|
||||
// asking the user for a filename to save as. If the chosen file exists, the
|
||||
// user is prompted whether they want to overwrite the file. Returns
|
||||
|
||||
@@ -20,6 +20,10 @@ func (b *FileBuilder) load() (string, error) {
|
||||
return b.run(false)
|
||||
}
|
||||
|
||||
func (b *FileBuilder) loadMultiple() ([]string, error) {
|
||||
return b.runMultiple()
|
||||
}
|
||||
|
||||
func (b *FileBuilder) save() (string, error) {
|
||||
return b.run(true)
|
||||
}
|
||||
@@ -49,6 +53,26 @@ func (b *FileBuilder) run(save bool) (string, error) {
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (b *FileBuilder) runMultiple() ([]string, error) {
|
||||
star := false
|
||||
var exts []string
|
||||
for _, filt := range b.Filters {
|
||||
for _, ext := range filt.Extensions {
|
||||
if ext == "*" {
|
||||
star = true
|
||||
} else {
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files, err := cocoa.MultiFileDlg(b.Dlg.Title, exts, star, b.StartDir, b.ShowHiddenFiles)
|
||||
if len(files) == 0 && err == nil {
|
||||
return nil, ErrCancelled
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
func (b *DirectoryBuilder) browse() (string, error) {
|
||||
f, err := cocoa.DirDlg(b.Dlg.Title, b.StartDir, b.ShowHiddenFiles)
|
||||
if f == "" && err == nil {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package dialog
|
||||
|
||||
// #cgo pkg-config: gtk+-3.0
|
||||
// #cgo LDFLAGS: -lX11
|
||||
// #include <X11/Xlib.h>
|
||||
// #include <gtk/gtk.h>
|
||||
// #include <stdlib.h>
|
||||
// static GtkWidget* msgdlg(GtkWindow *parent, GtkDialogFlags flags, GtkMessageType type, GtkButtonsType buttons, char *msg) {
|
||||
// return gtk_message_dialog_new(parent, flags, type, buttons, "%s", msg);
|
||||
// }
|
||||
// static GtkWidget* filedlg(char *title, GtkWindow *parent, GtkFileChooserAction action, char* acceptText) {
|
||||
// return gtk_file_chooser_dialog_new(title, parent, action, "Cancel", GTK_RESPONSE_CANCEL, acceptText, GTK_RESPONSE_ACCEPT, NULL);
|
||||
// }
|
||||
import "C"
|
||||
import "unsafe"
|
||||
|
||||
var initSuccess bool
|
||||
|
||||
func init() {
|
||||
C.XInitThreads()
|
||||
initSuccess = (C.gtk_init_check(nil, nil) == C.TRUE)
|
||||
}
|
||||
|
||||
func checkStatus() {
|
||||
if !initSuccess {
|
||||
panic("gtk initialisation failed; presumably no X server is available")
|
||||
}
|
||||
}
|
||||
|
||||
func closeDialog(dlg *C.GtkWidget) {
|
||||
C.gtk_widget_destroy(dlg)
|
||||
/* The Destroy call itself isn't enough to remove the dialog from the screen; apparently
|
||||
** that happens once the GTK main loop processes some further events. But if we're
|
||||
** in a non-GTK app the main loop isn't running, so we empty the event queue before
|
||||
** returning from the dialog functions.
|
||||
** Not sure how this interacts with an actual GTK app... */
|
||||
for C.gtk_events_pending() != 0 {
|
||||
C.gtk_main_iteration()
|
||||
}
|
||||
}
|
||||
|
||||
func runMsgDlg(defaultTitle string, flags C.GtkDialogFlags, msgtype C.GtkMessageType, buttons C.GtkButtonsType, b *MsgBuilder) C.gint {
|
||||
checkStatus()
|
||||
cmsg := C.CString(b.Msg)
|
||||
defer C.free(unsafe.Pointer(cmsg))
|
||||
dlg := C.msgdlg(nil, flags, msgtype, buttons, cmsg)
|
||||
ctitle := C.CString(firstOf(b.Dlg.Title, defaultTitle))
|
||||
defer C.free(unsafe.Pointer(ctitle))
|
||||
C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(dlg)), ctitle)
|
||||
defer closeDialog(dlg)
|
||||
return C.gtk_dialog_run((*C.GtkDialog)(unsafe.Pointer(dlg)))
|
||||
}
|
||||
|
||||
func (b *MsgBuilder) yesNo() bool {
|
||||
return runMsgDlg("Confirm?", 0, C.GTK_MESSAGE_QUESTION, C.GTK_BUTTONS_YES_NO, b) == C.GTK_RESPONSE_YES
|
||||
}
|
||||
|
||||
func (b *MsgBuilder) info() {
|
||||
runMsgDlg("Information", 0, C.GTK_MESSAGE_INFO, C.GTK_BUTTONS_OK, b)
|
||||
}
|
||||
|
||||
func (b *MsgBuilder) error() {
|
||||
runMsgDlg("Error", 0, C.GTK_MESSAGE_ERROR, C.GTK_BUTTONS_OK, b)
|
||||
}
|
||||
|
||||
func (b *FileBuilder) load() (string, error) {
|
||||
return chooseFile("Open File", "Open", C.GTK_FILE_CHOOSER_ACTION_OPEN, b)
|
||||
}
|
||||
|
||||
func (b *FileBuilder) save() (string, error) {
|
||||
f, err := chooseFile("Save File", "Save", C.GTK_FILE_CHOOSER_ACTION_SAVE, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func chooseFile(title string, buttonText string, action C.GtkFileChooserAction, b *FileBuilder) (string, error) {
|
||||
checkStatus()
|
||||
ctitle := C.CString(title)
|
||||
defer C.free(unsafe.Pointer(ctitle))
|
||||
cbuttonText := C.CString(buttonText)
|
||||
defer C.free(unsafe.Pointer(cbuttonText))
|
||||
dlg := C.filedlg(ctitle, nil, action, cbuttonText)
|
||||
fdlg := (*C.GtkFileChooser)(unsafe.Pointer(dlg))
|
||||
|
||||
for _, filt := range b.Filters {
|
||||
filter := C.gtk_file_filter_new()
|
||||
cdesc := C.CString(filt.Desc)
|
||||
defer C.free(unsafe.Pointer(cdesc))
|
||||
C.gtk_file_filter_set_name(filter, cdesc)
|
||||
|
||||
for _, ext := range filt.Extensions {
|
||||
cpattern := C.CString("*." + ext)
|
||||
defer C.free(unsafe.Pointer(cpattern))
|
||||
C.gtk_file_filter_add_pattern(filter, cpattern)
|
||||
}
|
||||
C.gtk_file_chooser_add_filter(fdlg, filter)
|
||||
}
|
||||
if b.StartDir != "" {
|
||||
cdir := C.CString(b.StartDir)
|
||||
defer C.free(unsafe.Pointer(cdir))
|
||||
C.gtk_file_chooser_set_current_folder(fdlg, cdir)
|
||||
}
|
||||
if b.StartFile != "" {
|
||||
cfile := C.CString(b.StartFile)
|
||||
defer C.free(unsafe.Pointer(cfile))
|
||||
C.gtk_file_chooser_set_current_name(fdlg, cfile)
|
||||
}
|
||||
if b.ShowHiddenFiles {
|
||||
C.gtk_file_chooser_set_show_hidden(fdlg, C.TRUE)
|
||||
}
|
||||
C.gtk_file_chooser_set_do_overwrite_confirmation(fdlg, C.TRUE)
|
||||
r := C.gtk_dialog_run((*C.GtkDialog)(unsafe.Pointer(dlg)))
|
||||
defer closeDialog(dlg)
|
||||
if r == C.GTK_RESPONSE_ACCEPT {
|
||||
return C.GoString(C.gtk_file_chooser_get_filename(fdlg)), nil
|
||||
}
|
||||
return "", ErrCancelled
|
||||
}
|
||||
|
||||
func (b *DirectoryBuilder) browse() (string, error) {
|
||||
return chooseFile("Open Folder", "Open", C.GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER, &FileBuilder{Dlg: b.Dlg, ShowHiddenFiles: b.ShowHiddenFiles})
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"github.com/TheTitanrain/w32"
|
||||
)
|
||||
|
||||
const multiFileBufferSize = w32.MAX_PATH * 10
|
||||
|
||||
type WinDlgError int
|
||||
|
||||
func (e WinDlgError) Error() string {
|
||||
return fmt.Sprintf("CommDlgExtendedError: %#x", e)
|
||||
return fmt.Sprintf("CommDlgExtendedError: %#x", int(e))
|
||||
}
|
||||
|
||||
func err() error {
|
||||
@@ -51,6 +53,57 @@ func (d filedlg) Filename() string {
|
||||
return string(utf16.Decode(d.buf[:i]))
|
||||
}
|
||||
|
||||
func (d filedlg) parseMultipleFilenames() []string {
|
||||
var files []string
|
||||
i := 0
|
||||
|
||||
// Find first null terminator (directory path)
|
||||
for i < len(d.buf) && d.buf[i] != 0 {
|
||||
i++
|
||||
}
|
||||
|
||||
if i >= len(d.buf) {
|
||||
return files
|
||||
}
|
||||
|
||||
// Get directory path
|
||||
dirPath := string(utf16.Decode(d.buf[:i]))
|
||||
i++ // Skip null terminator
|
||||
|
||||
// Check if there are more files (multiple selection)
|
||||
if i < len(d.buf) && d.buf[i] != 0 {
|
||||
// Multiple files selected - parse filenames
|
||||
for i < len(d.buf) {
|
||||
start := i
|
||||
// Find next null terminator
|
||||
for i < len(d.buf) && d.buf[i] != 0 {
|
||||
i++
|
||||
}
|
||||
if i >= len(d.buf) {
|
||||
break
|
||||
}
|
||||
|
||||
if start < i {
|
||||
filename := string(utf16.Decode(d.buf[start:i]))
|
||||
if dirPath != "" {
|
||||
files = append(files, dirPath+"\\"+filename)
|
||||
} else {
|
||||
files = append(files, filename)
|
||||
}
|
||||
}
|
||||
i++ // Skip null terminator
|
||||
if i >= len(d.buf) || d.buf[i] == 0 {
|
||||
break // End of list
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single file selected
|
||||
files = append(files, dirPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func (b *FileBuilder) load() (string, error) {
|
||||
d := openfile(w32.OFN_FILEMUSTEXIST|w32.OFN_NOCHANGEDIR, b)
|
||||
if w32.GetOpenFileName(d.opf) {
|
||||
@@ -59,6 +112,18 @@ func (b *FileBuilder) load() (string, error) {
|
||||
return "", err()
|
||||
}
|
||||
|
||||
func (b *FileBuilder) loadMultiple() ([]string, error) {
|
||||
d := openfile(w32.OFN_FILEMUSTEXIST|w32.OFN_NOCHANGEDIR|w32.OFN_ALLOWMULTISELECT|w32.OFN_EXPLORER, b)
|
||||
d.buf = make([]uint16, multiFileBufferSize)
|
||||
d.opf.File = utf16ptr(d.buf)
|
||||
d.opf.MaxFile = uint32(len(d.buf))
|
||||
|
||||
if w32.GetOpenFileName(d.opf) {
|
||||
return d.parseMultipleFilenames(), nil
|
||||
}
|
||||
return nil, err()
|
||||
}
|
||||
|
||||
func (b *FileBuilder) save() (string, error) {
|
||||
d := openfile(w32.OFN_OVERWRITEPROMPT|w32.OFN_NOCHANGEDIR, b)
|
||||
if w32.GetSaveFileName(d.opf) {
|
||||
@@ -76,15 +141,15 @@ func utf16ptr(utf16 []uint16) *uint16 {
|
||||
return (*uint16)(unsafe.Pointer(h.Data))
|
||||
}
|
||||
|
||||
func utf16slice(ptr *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)))
|
||||
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
i := 0
|
||||
for slice[len(slice)-1] != 0 {
|
||||
i++
|
||||
}
|
||||
hdr.Len = i
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr)))
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
return slice
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package dialog
|
||||
|
||||
func firstOf(args ...string) string {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package format
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package format
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
// package logrotate provides utilities for rotating logs
|
||||
// TODO (jmorgan): this most likely doesn't need it's own
|
||||
// package and can be moved to app where log files are created
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package logrotate
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ConnectivityStatus int
|
||||
|
||||
const (
|
||||
StatusUnknown ConnectivityStatus = iota
|
||||
StatusOnline
|
||||
StatusOffline
|
||||
)
|
||||
|
||||
type ConnectivityChangeHandler func(status ConnectivityStatus)
|
||||
|
||||
type Monitor struct {
|
||||
mu sync.RWMutex
|
||||
status ConnectivityStatus
|
||||
handlers []ConnectivityChangeHandler
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewMonitor() *Monitor {
|
||||
return &Monitor{
|
||||
status: StatusUnknown,
|
||||
handlers: make([]ConnectivityChangeHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
if m.stopChan != nil {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.stopChan = make(chan struct{})
|
||||
m.mu.Unlock()
|
||||
|
||||
m.startPlatformMonitor(ctx)
|
||||
}
|
||||
|
||||
func (m *Monitor) checkConnectivity() {
|
||||
online := m.checkPlatformConnectivity()
|
||||
|
||||
m.mu.Lock()
|
||||
oldStatus := m.status
|
||||
if online {
|
||||
m.status = StatusOnline
|
||||
} else {
|
||||
m.status = StatusOffline
|
||||
}
|
||||
handlers := m.handlers
|
||||
m.mu.Unlock()
|
||||
|
||||
if oldStatus != m.status {
|
||||
for _, handler := range handlers {
|
||||
handler(m.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) OnConnectivityChange(handler ConnectivityChangeHandler) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.handlers = append(m.handlers, handler)
|
||||
}
|
||||
|
||||
func (m *Monitor) IsOnline() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status == StatusOnline
|
||||
}
|
||||
|
||||
// Disconnected returns a channel that receives a signal when the network goes offline
|
||||
func (m *Monitor) Disconnected() <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
|
||||
m.OnConnectivityChange(func(status ConnectivityStatus) {
|
||||
if status == StatusOffline {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
// Don't block if already signaled
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ch
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (m *Monitor) startPlatformMonitor(ctx context.Context) {
|
||||
go m.watchNetworkChanges(ctx)
|
||||
}
|
||||
|
||||
func (m *Monitor) checkPlatformConnectivity() bool {
|
||||
// Check if we have active network interfaces
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "scutil", "--nwi")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
|
||||
// Check for active interfaces with IP addresses
|
||||
hasIPv4 := strings.Contains(outputStr, "IPv4") &&
|
||||
!strings.Contains(outputStr, "IPv4 : No addresses")
|
||||
hasIPv6 := strings.Contains(outputStr, "IPv6") &&
|
||||
!strings.Contains(outputStr, "IPv6 : No addresses")
|
||||
|
||||
if !hasIPv4 && !hasIPv6 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for active network interfaces
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Look for active ethernet (en) or VPN (utun) interfaces
|
||||
if strings.HasPrefix(line, "en") || strings.HasPrefix(line, "utun") {
|
||||
if strings.Contains(line, "flags") && !strings.Contains(line, "inactive") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Monitor) watchNetworkChanges(ctx context.Context) {
|
||||
// Use scutil to watch for network changes
|
||||
cmd := exec.CommandContext(ctx, "scutil")
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
defer cmd.Wait()
|
||||
|
||||
// Watch for network state changes
|
||||
stdin.Write([]byte("n.add State:/Network/Global/IPv4\n"))
|
||||
stdin.Write([]byte("n.add State:/Network/Global/IPv6\n"))
|
||||
stdin.Write([]byte("n.add State:/Network/Interface\n"))
|
||||
stdin.Write([]byte("n.watch\n"))
|
||||
|
||||
// Trigger initial check
|
||||
m.checkConnectivity()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopChan:
|
||||
return
|
||||
default:
|
||||
// Any output from scutil indicates a network change
|
||||
// Trigger connectivity check
|
||||
m.checkConnectivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
wininet = syscall.NewLazyDLL("wininet.dll")
|
||||
internetGetConnectedState = wininet.NewProc("InternetGetConnectedState")
|
||||
)
|
||||
|
||||
const INTERNET_CONNECTION_OFFLINE = 0x20
|
||||
|
||||
func (m *Monitor) startPlatformMonitor(ctx context.Context) {
|
||||
go m.watchNetworkChanges(ctx)
|
||||
}
|
||||
|
||||
func (m *Monitor) checkPlatformConnectivity() bool {
|
||||
// First check Windows Internet API
|
||||
if internetGetConnectedState.Find() == nil {
|
||||
var flags uint32
|
||||
r, _, _ := internetGetConnectedState.Call(
|
||||
uintptr(unsafe.Pointer(&flags)),
|
||||
0,
|
||||
)
|
||||
|
||||
if r == 1 && (flags&INTERNET_CONNECTION_OFFLINE) == 0 {
|
||||
// Also verify with netsh that interfaces are actually connected
|
||||
return m.checkWindowsInterfaces()
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Monitor) checkWindowsInterfaces() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "netsh", "interface", "show", "interface")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
line = strings.ToLower(strings.TrimSpace(line))
|
||||
|
||||
// Look for a “connected” interface that isn’t “disconnected” or “loopback”
|
||||
if strings.Contains(line, "connected") &&
|
||||
!strings.Contains(line, "disconnected") &&
|
||||
!strings.Contains(line, "loopback") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Monitor) watchNetworkChanges(ctx context.Context) {
|
||||
// Windows doesn't have a simple built-in tool like scutil,
|
||||
// so poll frequently to detect changes
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial check
|
||||
m.checkConnectivity()
|
||||
|
||||
var lastState bool = m.checkPlatformConnectivity()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
currentState := m.checkPlatformConnectivity()
|
||||
if currentState != lastState {
|
||||
lastState = currentState
|
||||
m.checkConnectivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -39,6 +41,11 @@ type InferenceCompute struct {
|
||||
VRAM string
|
||||
}
|
||||
|
||||
type InferenceInfo struct {
|
||||
Computes []InferenceCompute
|
||||
DefaultContextLength int
|
||||
}
|
||||
|
||||
func New(s *store.Store, devMode bool) *Server {
|
||||
p := resolvePath("ollama")
|
||||
return &Server{store: s, bin: p, dev: devMode}
|
||||
@@ -203,6 +210,11 @@ func (s *Server) cmd(ctx context.Context) (*exec.Cmd, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cloudDisabled, err := s.store.CloudDisabled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := commandContext(ctx, s.bin, "serve")
|
||||
cmd.Stdout, cmd.Stderr = s.log, s.log
|
||||
|
||||
@@ -222,14 +234,17 @@ func (s *Server) cmd(ctx context.Context) (*exec.Cmd, error) {
|
||||
if _, err := os.Stat(settings.Models); err == nil {
|
||||
env["OLLAMA_MODELS"] = settings.Models
|
||||
} else {
|
||||
slog.Warn("models path not accessible, clearing models setting", "path", settings.Models, "err", err)
|
||||
settings.Models = ""
|
||||
s.store.SetSettings(settings)
|
||||
slog.Warn("models path not accessible, using default", "path", settings.Models, "err", err)
|
||||
}
|
||||
}
|
||||
if settings.ContextLength > 0 {
|
||||
env["OLLAMA_CONTEXT_LENGTH"] = strconv.Itoa(settings.ContextLength)
|
||||
}
|
||||
if cloudDisabled {
|
||||
env["OLLAMA_NO_CLOUD"] = "1"
|
||||
} else {
|
||||
env["OLLAMA_NO_CLOUD"] = "0"
|
||||
}
|
||||
cmd.Env = []string{}
|
||||
for k, v := range env {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
@@ -260,11 +275,14 @@ func openRotatingLog() (io.WriteCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Attempt to retrive inference compute information from the server
|
||||
// Attempt to retrieve inference compute information from the server
|
||||
// log. Set ctx to timeout to control how long to wait for the logs to appear
|
||||
func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
||||
inference := []InferenceCompute{}
|
||||
marker := regexp.MustCompile(`inference compute.*library=`)
|
||||
func GetInferenceInfo(ctx context.Context) (*InferenceInfo, error) {
|
||||
info := &InferenceInfo{}
|
||||
computeMarker := regexp.MustCompile(`inference compute.*library=`)
|
||||
defaultCtxMarker := regexp.MustCompile(`vram-based default context`)
|
||||
defaultCtxRegex := regexp.MustCompile(`default_num_ctx=(\d+)`)
|
||||
|
||||
q := `inference compute.*%s=["]([^"]*)["]`
|
||||
nq := `inference compute.*%s=(\S+)\s`
|
||||
type regex struct {
|
||||
@@ -326,11 +344,12 @@ func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
match := marker.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
// Check for inference compute lines
|
||||
if computeMarker.MatchString(line) {
|
||||
ic := InferenceCompute{
|
||||
Library: get("library", line),
|
||||
Variant: get("variant", line),
|
||||
@@ -341,12 +360,25 @@ func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
||||
}
|
||||
|
||||
slog.Info("Matched", "inference compute", ic)
|
||||
inference = append(inference, ic)
|
||||
} else {
|
||||
// Break out on first non matching line after we start matching
|
||||
if len(inference) > 0 {
|
||||
return inference, nil
|
||||
info.Computes = append(info.Computes, ic)
|
||||
continue
|
||||
}
|
||||
// Check for default context length line
|
||||
if defaultCtxMarker.MatchString(line) {
|
||||
match := defaultCtxRegex.FindStringSubmatch(line)
|
||||
if len(match) > 1 {
|
||||
numCtx, err := strconv.Atoi(match[1])
|
||||
if err == nil {
|
||||
info.DefaultContextLength = numCtx
|
||||
slog.Info("Matched default context length", "default_num_ctx", numCtx)
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
// If we've found compute info but hit a non-matching line, return what we have
|
||||
// This handles older server versions that don't log the default context line
|
||||
if len(info.Computes) > 0 {
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -13,12 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-server-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
st := &store.Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
|
||||
defer st.Close() // Ensure database is closed before cleanup
|
||||
s := New(st, false)
|
||||
@@ -40,14 +37,10 @@ func TestServerCmd(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
defaultModels = filepath.Join(home, ".ollama", "models")
|
||||
os.MkdirAll(defaultModels, 0755)
|
||||
os.MkdirAll(defaultModels, 0o755)
|
||||
}
|
||||
|
||||
tmpModels, err := os.MkdirTemp("", "models")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpModels)
|
||||
tmpModels := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
@@ -102,12 +95,7 @@ func TestServerCmd(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-server-cmd-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
st := &store.Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
|
||||
defer st.Close() // Ensure database is closed before cleanup
|
||||
st.SetSettings(tt.settings)
|
||||
@@ -115,7 +103,7 @@ func TestServerCmd(t *testing.T) {
|
||||
store: st,
|
||||
}
|
||||
|
||||
cmd, err := s.cmd(context.Background())
|
||||
cmd, err := s.cmd(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("s.cmd() error = %v", err)
|
||||
}
|
||||
@@ -123,7 +111,7 @@ func TestServerCmd(t *testing.T) {
|
||||
for _, want := range tt.want {
|
||||
found := false
|
||||
for _, env := range cmd.Env {
|
||||
if strings.Contains(env, want) {
|
||||
if strings.HasPrefix(env, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -135,7 +123,7 @@ func TestServerCmd(t *testing.T) {
|
||||
|
||||
for _, dont := range tt.dont {
|
||||
for _, env := range cmd.Env {
|
||||
if strings.Contains(env, dont) {
|
||||
if strings.HasPrefix(env, dont) {
|
||||
t.Errorf("unexpected environment variable: %s", env)
|
||||
}
|
||||
}
|
||||
@@ -148,44 +136,119 @@ func TestServerCmd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInferenceComputer(t *testing.T) {
|
||||
func TestServerCmdCloudSettingEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
log string
|
||||
exp []InferenceCompute
|
||||
name string
|
||||
envValue string
|
||||
configContent string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "default cloud enabled",
|
||||
want: "OLLAMA_NO_CLOUD=0",
|
||||
},
|
||||
{
|
||||
name: "env disables cloud",
|
||||
envValue: "1",
|
||||
want: "OLLAMA_NO_CLOUD=1",
|
||||
},
|
||||
{
|
||||
name: "config disables cloud",
|
||||
configContent: `{"disable_ollama_cloud": true}`,
|
||||
want: "OLLAMA_NO_CLOUD=1",
|
||||
},
|
||||
{
|
||||
name: "invalid env disables cloud",
|
||||
envValue: "invalid",
|
||||
want: "OLLAMA_NO_CLOUD=1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
t.Setenv("USERPROFILE", tmpHome)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", tt.envValue)
|
||||
|
||||
if tt.configContent != "" {
|
||||
configDir := filepath.Join(tmpHome, ".ollama")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "server.json")
|
||||
if err := os.WriteFile(configPath, []byte(tt.configContent), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
st := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
defer st.Close()
|
||||
|
||||
s := &Server{store: st}
|
||||
cmd, err := s.cmd(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("s.cmd() error = %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, env := range cmd.Env {
|
||||
if env == tt.want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected environment variable %q in command env", tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInferenceInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
log string
|
||||
expComputes []InferenceCompute
|
||||
expDefaultCtxLen int
|
||||
}{
|
||||
{
|
||||
name: "metal",
|
||||
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
|
||||
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
|
||||
time=2025-06-30T09:23:07.417-07:00 level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="96.0 GiB" default_num_ctx=262144
|
||||
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
|
||||
`,
|
||||
exp: []InferenceCompute{{
|
||||
expComputes: []InferenceCompute{{
|
||||
Library: "metal",
|
||||
Driver: "0.0",
|
||||
VRAM: "96.0 GiB",
|
||||
}},
|
||||
expDefaultCtxLen: 262144,
|
||||
},
|
||||
{
|
||||
name: "cpu",
|
||||
log: `time=2025-07-01T17:59:51.470Z level=INFO source=gpu.go:377 msg="no compatible GPUs were discovered"
|
||||
time=2025-07-01T17:59:51.470Z level=INFO source=types.go:130 msg="inference compute" id=0 library=cpu variant="" compute="" driver=0.0 name="" total="31.3 GiB" available="30.4 GiB"
|
||||
time=2025-07-01T17:59:51.471Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="31.3 GiB" default_num_ctx=32768
|
||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||
`,
|
||||
exp: []InferenceCompute{{
|
||||
expComputes: []InferenceCompute{{
|
||||
Library: "cpu",
|
||||
Driver: "0.0",
|
||||
VRAM: "31.3 GiB",
|
||||
}},
|
||||
expDefaultCtxLen: 32768,
|
||||
},
|
||||
{
|
||||
name: "cuda1",
|
||||
log: `time=2025-07-01T19:33:43.162Z level=DEBUG source=amd_linux.go:419 msg="amdgpu driver not detected /sys/module/amdgpu"
|
||||
releasing cuda driver library
|
||||
time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference compute" id=GPU-452cac9f-6960-839c-4fb3-0cec83699196 library=cuda variant=v12 compute=6.1 driver=12.7 name="NVIDIA GeForce GT 1030" total="3.9 GiB" available="3.9 GiB"
|
||||
time=2025-07-01T19:33:43.163Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="3.9 GiB" default_num_ctx=4096
|
||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||
`,
|
||||
exp: []InferenceCompute{{
|
||||
expComputes: []InferenceCompute{{
|
||||
Library: "cuda",
|
||||
Variant: "v12",
|
||||
Compute: "6.1",
|
||||
@@ -193,6 +256,7 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
||||
Name: "NVIDIA GeForce GT 1030",
|
||||
VRAM: "3.9 GiB",
|
||||
}},
|
||||
expDefaultCtxLen: 4096,
|
||||
},
|
||||
{
|
||||
name: "frank",
|
||||
@@ -200,9 +264,10 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
||||
releasing cuda driver library
|
||||
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-d6de3398-9932-6902-11ec-fee8e424c8a2 library=cuda variant=v12 compute=7.5 driver=12.8 name="NVIDIA GeForce RTX 2080 Ti" total="10.6 GiB" available="10.4 GiB"
|
||||
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-9abb57639fa80c50 library=rocm variant="" compute=gfx1030 driver=6.3 name=1002:73bf total="16.0 GiB" available="1.3 GiB"
|
||||
time=2025-07-01T19:36:13.316Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="26.6 GiB" default_num_ctx=32768
|
||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||
`,
|
||||
exp: []InferenceCompute{
|
||||
expComputes: []InferenceCompute{
|
||||
{
|
||||
Library: "cuda",
|
||||
Variant: "v12",
|
||||
@@ -219,49 +284,56 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
||||
VRAM: "16.0 GiB",
|
||||
},
|
||||
},
|
||||
expDefaultCtxLen: 32768,
|
||||
},
|
||||
{
|
||||
name: "missing_default_context",
|
||||
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
|
||||
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
|
||||
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
|
||||
`,
|
||||
expComputes: []InferenceCompute{{
|
||||
Library: "metal",
|
||||
Driver: "0.0",
|
||||
VRAM: "96.0 GiB",
|
||||
}},
|
||||
expDefaultCtxLen: 0, // No default context line, should return 0
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", tt.name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
serverLogPath = filepath.Join(tmpDir, "server.log")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
err = os.WriteFile(serverLogPath, []byte(tt.log), 0644)
|
||||
err := os.WriteFile(serverLogPath, []byte(tt.log), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
ics, err := GetInferenceComputer(ctx)
|
||||
info, err := GetInferenceInfo(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf(" failed to get inference compute: %v", err)
|
||||
t.Fatalf("failed to get inference info: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(ics, tt.exp) {
|
||||
t.Fatalf("got:\n%#v\nwant:\n%#v", ics, tt.exp)
|
||||
if !reflect.DeepEqual(info.Computes, tt.expComputes) {
|
||||
t.Fatalf("computes mismatch\ngot:\n%#v\nwant:\n%#v", info.Computes, tt.expComputes)
|
||||
}
|
||||
if info.DefaultContextLength != tt.expDefaultCtxLen {
|
||||
t.Fatalf("default context length mismatch: got %d, want %d", info.DefaultContextLength, tt.expDefaultCtxLen)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInferenceComputerTimeout(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
func TestGetInferenceInfoTimeout(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
tmpDir, err := os.MkdirTemp("", "timeouttest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
serverLogPath = filepath.Join(tmpDir, "server.log")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
err = os.WriteFile(serverLogPath, []byte("foo\nbar\nbaz\n"), 0644)
|
||||
err := os.WriteFile(serverLogPath, []byte("foo\nbar\nbaz\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
||||
}
|
||||
_, err = GetInferenceComputer(ctx)
|
||||
_, err = GetInferenceInfo(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build darwin
|
||||
|
||||
package server
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var pidFile = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "ollama.pid")
|
||||
var serverLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "server.log")
|
||||
var (
|
||||
pidFile = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "ollama.pid")
|
||||
serverLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "server.log")
|
||||
)
|
||||
|
||||
func commandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, name, arg...)
|
||||
@@ -57,7 +59,7 @@ func reapServers() error {
|
||||
if err != nil {
|
||||
// No ollama processes found
|
||||
slog.Debug("no ollama processes found")
|
||||
return nil
|
||||
return nil //nolint:nilerr
|
||||
}
|
||||
|
||||
pidsStr := strings.TrimSpace(string(output))
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var pidFile = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "ollama.pid")
|
||||
var serverLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "server.log")
|
||||
var (
|
||||
pidFile = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "ollama.pid")
|
||||
serverLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "server.log")
|
||||
)
|
||||
|
||||
func commandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, name, arg...)
|
||||
@@ -111,7 +113,7 @@ func reapServers() error {
|
||||
if err != nil {
|
||||
// No ollama processes found
|
||||
slog.Debug("no ollama processes found")
|
||||
return nil
|
||||
return nil //nolint:nilerr
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
|
||||
128
app/store/cloud_config.go
Normal file
128
app/store/cloud_config.go
Normal file
@@ -0,0 +1,128 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
const serverConfigFilename = "server.json"
|
||||
|
||||
type serverConfig struct {
|
||||
DisableOllamaCloud bool `json:"disable_ollama_cloud,omitempty"`
|
||||
}
|
||||
|
||||
// CloudDisabled returns whether cloud features should be disabled.
|
||||
// The source of truth is: OLLAMA_NO_CLOUD OR ~/.ollama/server.json:disable_ollama_cloud.
|
||||
func (s *Store) CloudDisabled() (bool, error) {
|
||||
disabled, _, err := s.CloudStatus()
|
||||
return disabled, err
|
||||
}
|
||||
|
||||
// CloudStatus returns whether cloud is disabled and the source of that decision.
|
||||
// Source is one of: "none", "env", "config", "both".
|
||||
func (s *Store) CloudStatus() (bool, string, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
configDisabled, err := readServerConfigCloudDisabled()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
envDisabled := envconfig.NoCloudEnv()
|
||||
return envDisabled || configDisabled, cloudStatusSource(envDisabled, configDisabled), nil
|
||||
}
|
||||
|
||||
// SetCloudEnabled writes the cloud setting to ~/.ollama/server.json.
|
||||
func (s *Store) SetCloudEnabled(enabled bool) error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
return setCloudEnabled(enabled)
|
||||
}
|
||||
|
||||
func setCloudEnabled(enabled bool) error {
|
||||
configPath, err := serverConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create server config directory: %w", err)
|
||||
}
|
||||
|
||||
configMap := map[string]any{}
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &configMap); err != nil {
|
||||
// If the existing file is invalid JSON, overwrite with a fresh object.
|
||||
configMap = map[string]any{}
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read server config: %w", err)
|
||||
}
|
||||
|
||||
configMap["disable_ollama_cloud"] = !enabled
|
||||
|
||||
data, err := json.MarshalIndent(configMap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal server config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write server config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readServerConfigCloudDisabled() (bool, error) {
|
||||
configPath, err := serverConfigPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read server config: %w", err)
|
||||
}
|
||||
|
||||
var cfg serverConfig
|
||||
// Invalid or unexpected JSON should not block startup; treat as default.
|
||||
if json.Unmarshal(data, &cfg) == nil {
|
||||
return cfg.DisableOllamaCloud, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func serverConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".ollama", serverConfigFilename), nil
|
||||
}
|
||||
|
||||
func cloudStatusSource(envDisabled bool, configDisabled bool) string {
|
||||
switch {
|
||||
case envDisabled && configDisabled:
|
||||
return "both"
|
||||
case envDisabled:
|
||||
return "env"
|
||||
case configDisabled:
|
||||
return "config"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
130
app/store/cloud_config_test.go
Normal file
130
app/store/cloud_config_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCloudDisabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
configContent string
|
||||
wantDisabled bool
|
||||
wantSource string
|
||||
}{
|
||||
{
|
||||
name: "default enabled",
|
||||
wantDisabled: false,
|
||||
wantSource: "none",
|
||||
},
|
||||
{
|
||||
name: "env disables cloud",
|
||||
envValue: "1",
|
||||
wantDisabled: true,
|
||||
wantSource: "env",
|
||||
},
|
||||
{
|
||||
name: "config disables cloud",
|
||||
configContent: `{"disable_ollama_cloud": true}`,
|
||||
wantDisabled: true,
|
||||
wantSource: "config",
|
||||
},
|
||||
{
|
||||
name: "env and config",
|
||||
envValue: "1",
|
||||
configContent: `{"disable_ollama_cloud": false}`,
|
||||
wantDisabled: true,
|
||||
wantSource: "env",
|
||||
},
|
||||
{
|
||||
name: "invalid config is ignored",
|
||||
configContent: `{bad`,
|
||||
wantDisabled: false,
|
||||
wantSource: "none",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
setTestHome(t, tmpHome)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", tt.envValue)
|
||||
|
||||
if tt.configContent != "" {
|
||||
configDir := filepath.Join(tmpHome, ".ollama")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, serverConfigFilename)
|
||||
if err := os.WriteFile(configPath, []byte(tt.configContent), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Store{DBPath: filepath.Join(tmpHome, "db.sqlite")}
|
||||
defer s.Close()
|
||||
|
||||
disabled, err := s.CloudDisabled()
|
||||
if err != nil {
|
||||
t.Fatalf("CloudDisabled() error = %v", err)
|
||||
}
|
||||
if disabled != tt.wantDisabled {
|
||||
t.Fatalf("CloudDisabled() = %v, want %v", disabled, tt.wantDisabled)
|
||||
}
|
||||
|
||||
statusDisabled, source, err := s.CloudStatus()
|
||||
if err != nil {
|
||||
t.Fatalf("CloudStatus() error = %v", err)
|
||||
}
|
||||
if statusDisabled != tt.wantDisabled {
|
||||
t.Fatalf("CloudStatus() disabled = %v, want %v", statusDisabled, tt.wantDisabled)
|
||||
}
|
||||
if source != tt.wantSource {
|
||||
t.Fatalf("CloudStatus() source = %v, want %v", source, tt.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCloudEnabled(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
setTestHome(t, tmpHome)
|
||||
|
||||
configDir := filepath.Join(tmpHome, ".ollama")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, serverConfigFilename)
|
||||
if err := os.WriteFile(configPath, []byte(`{"another_key":"value","disable_ollama_cloud":true}`), 0o644); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
s := &Store{DBPath: filepath.Join(tmpHome, "db.sqlite")}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.SetCloudEnabled(true); err != nil {
|
||||
t.Fatalf("SetCloudEnabled(true) error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
if got["disable_ollama_cloud"] != false {
|
||||
t.Fatalf("disable_ollama_cloud = %v, want false", got["disable_ollama_cloud"])
|
||||
}
|
||||
if got["another_key"] != "value" {
|
||||
t.Fatalf("another_key = %v, want value", got["another_key"])
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -7,12 +9,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 12
|
||||
const currentSchemaVersion = 15
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -71,7 +73,7 @@ func (db *database) init() error {
|
||||
agent BOOLEAN NOT NULL DEFAULT 0,
|
||||
tools BOOLEAN NOT NULL DEFAULT 0,
|
||||
working_dir TEXT NOT NULL DEFAULT '',
|
||||
context_length INTEGER NOT NULL DEFAULT 4096,
|
||||
context_length INTEGER NOT NULL DEFAULT 0,
|
||||
window_width INTEGER NOT NULL DEFAULT 0,
|
||||
window_height INTEGER NOT NULL DEFAULT 0,
|
||||
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
@@ -82,7 +84,9 @@ func (db *database) init() error {
|
||||
sidebar_open BOOLEAN 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,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -242,6 +246,24 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v11 to v12: %w", err)
|
||||
}
|
||||
version = 12
|
||||
case 12:
|
||||
// add cloud_setting_migrated column to settings table
|
||||
if err := db.migrateV12ToV13(); err != nil {
|
||||
return fmt.Errorf("migrate v12 to v13: %w", err)
|
||||
}
|
||||
version = 13
|
||||
case 13:
|
||||
// change default context_length from 4096 to 0 (VRAM-based tiered defaults)
|
||||
if err := db.migrateV13ToV14(); err != nil {
|
||||
return fmt.Errorf("migrate v13 to v14: %w", err)
|
||||
}
|
||||
version = 14
|
||||
case 14:
|
||||
// add auto_update_enabled column to settings table
|
||||
if err := db.migrateV14ToV15(); err != nil {
|
||||
return fmt.Errorf("migrate v14 to v15: %w", err)
|
||||
}
|
||||
version = 15
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -254,7 +276,6 @@ func (db *database) migrate() error {
|
||||
|
||||
// migrateV1ToV2 adds the context_length column to the settings table
|
||||
func (db *database) migrateV1ToV2() error {
|
||||
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN context_length INTEGER NOT NULL DEFAULT 4096;`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add context_length column: %w", err)
|
||||
@@ -294,6 +315,7 @@ func (db *database) migrateV2ToV3() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) migrateV3ToV4() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE messages ADD COLUMN tool_result TEXT;`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
@@ -413,7 +435,6 @@ func (db *database) migrateV9ToV10() error {
|
||||
);
|
||||
UPDATE settings SET schema_version = 10;
|
||||
`)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create users table: %w", err)
|
||||
}
|
||||
@@ -451,6 +472,52 @@ func (db *database) migrateV11ToV12() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV12ToV13 adds cloud_setting_migrated to settings.
|
||||
func (db *database) migrateV12ToV13() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add cloud_setting_migrated column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 13`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV13ToV14 changes the default context_length from 4096 to 0.
|
||||
// When context_length is 0, the ollama server uses VRAM-based tiered defaults.
|
||||
func (db *database) migrateV13ToV14() error {
|
||||
_, err := db.conn.Exec(`UPDATE settings SET context_length = 0 WHERE context_length = 4096`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update context_length default: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 14`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV14ToV15 adds the auto_update_enabled column to the settings table
|
||||
func (db *database) migrateV14ToV15() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN auto_update_enabled BOOLEAN NOT NULL DEFAULT 1`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add auto_update_enabled column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 15`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -481,19 +548,11 @@ func (db *database) cleanupOrphanedData() error {
|
||||
}
|
||||
|
||||
func duplicateColumnError(err error) bool {
|
||||
if sqlite3Err, ok := err.(sqlite3.Error); ok {
|
||||
return sqlite3Err.Code == sqlite3.ErrError &&
|
||||
strings.Contains(sqlite3Err.Error(), "duplicate column name")
|
||||
}
|
||||
return false
|
||||
return err != nil && strings.Contains(err.Error(), "duplicate column name")
|
||||
}
|
||||
|
||||
func columnNotExists(err error) bool {
|
||||
if sqlite3Err, ok := err.(sqlite3.Error); ok {
|
||||
return sqlite3Err.Code == sqlite3.ErrError &&
|
||||
strings.Contains(sqlite3Err.Error(), "no such column")
|
||||
}
|
||||
return false
|
||||
return err != nil && strings.Contains(err.Error(), "no such column")
|
||||
}
|
||||
|
||||
func (db *database) getAllChats() ([]Chat, error) {
|
||||
@@ -1107,10 +1166,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, airplane_mode, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level
|
||||
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
|
||||
FROM settings
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.AirplaneMode, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel)
|
||||
|
||||
`).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)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1120,15 +1178,41 @@ func (db *database) getSettings() (Settings, error) {
|
||||
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, airplane_mode = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.AirplaneMode, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) isCloudSettingMigrated() (bool, error) {
|
||||
var migrated bool
|
||||
err := db.conn.QueryRow("SELECT cloud_setting_migrated FROM settings").Scan(&migrated)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get cloud setting migration status: %w", err)
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
func (db *database) setCloudSettingMigrated(migrated bool) error {
|
||||
_, err := db.conn.Exec("UPDATE settings SET cloud_setting_migrated = ?", migrated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cloud setting migration status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) getAirplaneMode() (bool, error) {
|
||||
var airplaneMode bool
|
||||
err := db.conn.QueryRow("SELECT airplane_mode FROM settings").Scan(&airplaneMode)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get airplane_mode: %w", err)
|
||||
}
|
||||
return airplaneMode, nil
|
||||
}
|
||||
|
||||
func (db *database) getWindowSize() (int, int, error) {
|
||||
var width, height int
|
||||
err := db.conn.QueryRow("SELECT window_width, window_height FROM settings").Scan(&width, &height)
|
||||
@@ -1187,7 +1271,6 @@ func (db *database) getUser() (*User, error) {
|
||||
FROM users
|
||||
LIMIT 1
|
||||
`).Scan(&user.Name, &user.Email, &user.Plan, &user.CachedAt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // No user cached yet
|
||||
@@ -1207,7 +1290,6 @@ func (db *database) setUser(user User) error {
|
||||
INSERT INTO users (name, email, plan, cached_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, user.Name, user.Email, user.Plan, user.CachedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("set user: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -16,12 +18,7 @@ import (
|
||||
|
||||
func TestSchemaMigrations(t *testing.T) {
|
||||
t.Run("schema comparison after migration", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "migration-schema-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
migratedDBPath := filepath.Join(tmpDir, "migrated.db")
|
||||
migratedDB := loadV2Schema(t, migratedDBPath)
|
||||
defer migratedDB.Close()
|
||||
@@ -55,12 +52,7 @@ func TestSchemaMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("idempotent migrations", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "migration-idempotent-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db := loadV2Schema(t, dbPath)
|
||||
defer db.Close()
|
||||
@@ -85,12 +77,7 @@ func TestSchemaMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("init database has correct schema version", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "schema-version-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
@@ -111,14 +98,46 @@ func TestSchemaMigrations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrationV13ToV14ContextLength(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()
|
||||
|
||||
_, err = db.conn.Exec("UPDATE settings SET context_length = 4096, schema_version = 13")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed v13 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v13 to v14 failed: %v", err)
|
||||
}
|
||||
|
||||
var contextLength int
|
||||
if err := db.conn.QueryRow("SELECT context_length FROM settings").Scan(&contextLength); err != nil {
|
||||
t.Fatalf("failed to read context_length: %v", err)
|
||||
}
|
||||
|
||||
if contextLength != 0 {
|
||||
t.Fatalf("expected context_length to migrate to 0, got %d", contextLength)
|
||||
}
|
||||
|
||||
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 TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "cascade-delete-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
@@ -214,12 +233,7 @@ func TestChatDeletionWithCascade(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("foreign keys are enabled", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "foreign-keys-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
@@ -241,12 +255,7 @@ func TestChatDeletionWithCascade(t *testing.T) {
|
||||
// This test is only relevant for v8 migrations, but we keep it here for now
|
||||
// since it's a useful test to ensure that we don't introduce any new orphaned data
|
||||
t.Run("cleanup orphaned data", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "orphaned-data-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -48,7 +50,7 @@ func (s *Store) ImgDir() string {
|
||||
// ImgToFile saves image data to disk and returns ImageData reference
|
||||
func (s *Store) ImgToFile(chatID string, imageBytes []byte, filename, mimeType string) (Image, error) {
|
||||
baseImageDir := s.ImgDir()
|
||||
if err := os.MkdirAll(baseImageDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(baseImageDir, 0o755); err != nil {
|
||||
return Image{}, fmt.Errorf("create base image directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ func (s *Store) ImgToFile(chatID string, imageBytes []byte, filename, mimeType s
|
||||
|
||||
// Create chat-specific subdirectory within the root
|
||||
chatDir := sanitize(chatID)
|
||||
if err := root.Mkdir(chatDir, 0755); err != nil && !os.IsExist(err) {
|
||||
if err := root.Mkdir(chatDir, 0o755); err != nil && !os.IsExist(err) {
|
||||
return Image{}, fmt.Errorf("create chat directory: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -9,12 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestConfigMigration(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-migration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
// Create a legacy config.json
|
||||
legacyConfig := legacyData{
|
||||
ID: "test-device-id-12345",
|
||||
@@ -27,7 +24,7 @@ func TestConfigMigration(t *testing.T) {
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
if err := os.WriteFile(configPath, configData, 0644); err != nil {
|
||||
if err := os.WriteFile(configPath, configData, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -89,12 +86,7 @@ func TestConfigMigration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNoConfigToMigrate(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-no-migration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
// Override the legacy config path for testing
|
||||
oldLegacyConfigPath := legacyConfigPath
|
||||
legacyConfigPath = filepath.Join(tmpDir, "config.json")
|
||||
@@ -135,6 +127,65 @@ func TestNoConfigToMigrate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMigrationFromAirplaneMode(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
setTestHome(t, tmpHome)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "")
|
||||
|
||||
dbPath := filepath.Join(tmpHome, "db.sqlite")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec("UPDATE settings SET airplane_mode = 1, cloud_setting_migrated = 0"); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("failed to seed airplane migration state: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
s := Store{DBPath: dbPath}
|
||||
defer s.Close()
|
||||
|
||||
// Trigger DB initialization + one-time cloud migration.
|
||||
if _, err := s.ID(); err != nil {
|
||||
t.Fatalf("failed to initialize store: %v", err)
|
||||
}
|
||||
|
||||
disabled, err := s.CloudDisabled()
|
||||
if err != nil {
|
||||
t.Fatalf("CloudDisabled() error: %v", err)
|
||||
}
|
||||
if !disabled {
|
||||
t.Fatal("expected cloud to be disabled after migrating airplane_mode=true")
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpHome, ".ollama", serverConfigFilename)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated server config: %v", err)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("failed to parse migrated server config: %v", err)
|
||||
}
|
||||
if cfg["disable_ollama_cloud"] != true {
|
||||
t.Fatalf("disable_ollama_cloud = %v, want true", cfg["disable_ollama_cloud"])
|
||||
}
|
||||
|
||||
var airplaneMode, migrated bool
|
||||
if err := s.db.conn.QueryRow("SELECT airplane_mode, cloud_setting_migrated FROM settings").Scan(&airplaneMode, &migrated); err != nil {
|
||||
t.Fatalf("failed to read migration flags from DB: %v", err)
|
||||
}
|
||||
if !airplaneMode {
|
||||
t.Fatal("expected legacy airplane_mode value to remain unchanged")
|
||||
}
|
||||
if !migrated {
|
||||
t.Fatal("expected cloud_setting_migrated to be true")
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
v1Schema = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -197,11 +248,7 @@ const (
|
||||
)
|
||||
|
||||
func TestMigrationFromEpoc(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-migration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
tmpDir := t.TempDir()
|
||||
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
|
||||
defer s.Close()
|
||||
// Open database connection
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSchemaVersioning(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-schema-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
// Override legacy config path to avoid migration logs
|
||||
oldLegacyConfigPath := legacyConfigPath
|
||||
legacyConfigPath = filepath.Join(tmpDir, "config.json")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
// Package store provides a simple JSON file store for the desktop application
|
||||
// to save and load data such as ollama server configuration, messages,
|
||||
// login information and more.
|
||||
@@ -147,13 +149,10 @@ type Settings struct {
|
||||
// ContextLength specifies the context length for the ollama server (using OLLAMA_CONTEXT_LENGTH)
|
||||
ContextLength int
|
||||
|
||||
// AirplaneMode when true, turns off Ollama Turbo features and only uses local models
|
||||
AirplaneMode bool
|
||||
|
||||
// TurboEnabled indicates if Ollama Turbo features are enabled
|
||||
TurboEnabled bool
|
||||
|
||||
// Maps gpt-oss specfic frontend name' BrowserToolEnabled' to db field 'websearch_enabled'
|
||||
// Maps gpt-oss specific frontend name' BrowserToolEnabled' to db field 'websearch_enabled'
|
||||
WebSearchEnabled bool
|
||||
|
||||
// ThinkEnabled indicates if thinking is enabled
|
||||
@@ -167,6 +166,9 @@ type Settings struct {
|
||||
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -228,7 +230,7 @@ func (s *Store) ensureDB() error {
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -257,6 +259,40 @@ func (s *Store) ensureDB() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Run one-time migration from legacy airplane_mode behavior.
|
||||
if err := s.migrateCloudSetting(database); err != nil {
|
||||
return fmt.Errorf("migrate cloud setting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateCloudSetting migrates legacy airplane_mode into server.json exactly once.
|
||||
// After this, cloud state is sourced from server.json OR OLLAMA_NO_CLOUD.
|
||||
func (s *Store) migrateCloudSetting(database *database) error {
|
||||
migrated, err := database.isCloudSettingMigrated()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if migrated {
|
||||
return nil
|
||||
}
|
||||
|
||||
airplaneMode, err := database.getAirplaneMode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if airplaneMode {
|
||||
if err := setCloudEnabled(false); err != nil {
|
||||
return fmt.Errorf("migrate airplane_mode to cloud disabled: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.setCloudSettingMigrated(true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -174,10 +175,7 @@ func TestStore(t *testing.T) {
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "ollama-store-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Override legacy config path to ensure no migration happens
|
||||
oldLegacyConfigPath := legacyConfigPath
|
||||
@@ -188,7 +186,6 @@ func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
cleanup := func() {
|
||||
s.Close()
|
||||
legacyConfigPath = oldLegacyConfigPath
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return s, cleanup
|
||||
|
||||
11
app/store/test_home_test.go
Normal file
11
app/store/test_home_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func setTestHome(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
2
app/store/testdata/schema.sql
vendored
2
app/store/testdata/schema.sql
vendored
@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
agent BOOLEAN NOT NULL DEFAULT 0,
|
||||
tools BOOLEAN NOT NULL DEFAULT 0,
|
||||
working_dir TEXT NOT NULL DEFAULT '',
|
||||
context_length INTEGER NOT NULL DEFAULT 4096,
|
||||
context_length INTEGER NOT NULL DEFAULT 0,
|
||||
window_width INTEGER NOT NULL DEFAULT 0,
|
||||
window_height INTEGER NOT NULL DEFAULT 0,
|
||||
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BashCommand executes non-destructive bash commands
|
||||
type BashCommand struct{}
|
||||
|
||||
func (b *BashCommand) Name() string {
|
||||
return "bash_command"
|
||||
}
|
||||
|
||||
func (b *BashCommand) Description() string {
|
||||
return "Execute non-destructive bash commands safely"
|
||||
}
|
||||
|
||||
func (b *BashCommand) Prompt() string {
|
||||
return `For bash commands:
|
||||
1. Only use safe, non-destructive commands like: ls, pwd, echo, cat, grep, ps, df, du, find, which, whoami, date, uptime, uname, wc, head, tail, sort, uniq
|
||||
2. For searching files and content:
|
||||
- Use grep -r "keyword" . to recursively search for keywords in files
|
||||
- Use find . -name "*keyword*" to search for files by name
|
||||
- Use find . -type f -exec grep "keyword" {} \; to search file contents
|
||||
3. Never use dangerous flags like --delete, --remove, -rf, -fr, --modify, --write, --exec
|
||||
4. Commands will timeout after 30 seconds by default
|
||||
5. Always check command output for errors and handle them appropriately
|
||||
6. Before running any commands:
|
||||
- Use ls to understand directory structure
|
||||
- Use cat/head/tail to inspect file contents
|
||||
- Plan your search strategy based on the context`
|
||||
}
|
||||
|
||||
func (b *BashCommand) Schema() map[string]any {
|
||||
schemaBytes := []byte(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum execution time in seconds (default: 30)",
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}`)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
|
||||
return nil
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func (b *BashCommand) Execute(ctx context.Context, args map[string]any) (any, error) {
|
||||
// Extract command
|
||||
cmd, ok := args["command"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("command parameter is required and must be a string")
|
||||
}
|
||||
|
||||
// Get optional timeout
|
||||
timeoutSeconds := 30
|
||||
if t, ok := args["timeout_seconds"].(float64); ok {
|
||||
timeoutSeconds = int(t)
|
||||
}
|
||||
|
||||
// List of allowed commands (exact matches or prefixes)
|
||||
allowedCommands := []string{
|
||||
"ls", "pwd", "echo", "cat", "grep",
|
||||
"ps", "df", "du", "find", "which",
|
||||
"whoami", "date", "uptime", "uname",
|
||||
"wc", "head", "tail", "sort", "uniq",
|
||||
}
|
||||
|
||||
// Split the command to get the base command
|
||||
cmdParts := strings.Fields(cmd)
|
||||
if len(cmdParts) == 0 {
|
||||
return nil, fmt.Errorf("empty command")
|
||||
}
|
||||
baseCmd := cmdParts[0]
|
||||
|
||||
// Check if the command is allowed
|
||||
allowed := false
|
||||
for _, allowedCmd := range allowedCommands {
|
||||
if baseCmd == allowedCmd {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("command not in allowed list: %s", baseCmd)
|
||||
}
|
||||
|
||||
// Additional safety checks for arguments
|
||||
dangerousFlags := []string{
|
||||
"--delete", "--remove", "-rf", "-fr",
|
||||
"--modify", "--write", "--exec",
|
||||
}
|
||||
|
||||
cmdLower := strings.ToLower(cmd)
|
||||
for _, flag := range dangerousFlags {
|
||||
if strings.Contains(cmdLower, flag) {
|
||||
return nil, fmt.Errorf("command contains dangerous flag: %s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// Create command with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Execute command
|
||||
execCmd := exec.CommandContext(ctx, "bash", "-c", cmd)
|
||||
output, err := execCmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("command timed out after %d seconds", timeoutSeconds)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command execution failed: %w", err)
|
||||
}
|
||||
|
||||
// Return result directly as a map
|
||||
return map[string]any{
|
||||
"command": cmd,
|
||||
"output": string(output),
|
||||
"success": true,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBashCommand_Name(t *testing.T) {
|
||||
cmd := &BashCommand{}
|
||||
if name := cmd.Name(); name != "bash_command" {
|
||||
t.Errorf("Expected name 'bash_command', got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCommand_Execute(t *testing.T) {
|
||||
cmd := &BashCommand{}
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
wantErr bool
|
||||
errContains string
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "valid echo command",
|
||||
input: map[string]any{
|
||||
"command": "echo 'hello world'",
|
||||
},
|
||||
wantErr: false,
|
||||
wantOutput: "hello world\n",
|
||||
},
|
||||
{
|
||||
name: "valid ls command",
|
||||
input: map[string]any{
|
||||
"command": "ls -l",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid command",
|
||||
input: map[string]any{
|
||||
"command": "rm -rf /",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "command not in allowed list",
|
||||
},
|
||||
{
|
||||
name: "dangerous flag",
|
||||
input: map[string]any{
|
||||
"command": "find . --delete",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "dangerous flag",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := cmd.Execute(ctx, tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check result type and fields
|
||||
response, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected result to be map[string]any")
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
success, ok := response["success"].(bool)
|
||||
if !ok || !success {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
command, ok := response["command"].(string)
|
||||
if !ok || command == "" {
|
||||
t.Error("Expected command to be non-empty string")
|
||||
}
|
||||
|
||||
output, ok := response["output"].(string)
|
||||
if !ok {
|
||||
t.Error("Expected output to be string")
|
||||
} else if tt.wantOutput != "" && output != tt.wantOutput {
|
||||
t.Errorf("Expected output '%s', got '%s'", tt.wantOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCommand_InvalidInput(t *testing.T) {
|
||||
cmd := &BashCommand{}
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
input: map[string]any{},
|
||||
errContains: "command parameter is required",
|
||||
},
|
||||
{
|
||||
name: "empty command",
|
||||
input: map[string]any{
|
||||
"command": "",
|
||||
},
|
||||
errContains: "empty command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := cmd.Execute(ctx, tt.input)
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCommand_OutputFormat(t *testing.T) {
|
||||
cmd := &BashCommand{}
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with a simple echo command
|
||||
input := map[string]any{
|
||||
"command": "echo 'test output'",
|
||||
}
|
||||
|
||||
result, err := cmd.Execute(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the result is a map[string]any
|
||||
response, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Result is not a map[string]any")
|
||||
}
|
||||
|
||||
// Check all expected fields exist
|
||||
requiredFields := []string{"command", "output", "success"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := response[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify output is plain text
|
||||
output, ok := response["output"].(string)
|
||||
if !ok {
|
||||
t.Error("Output field is not a string")
|
||||
} else {
|
||||
// Output should contain 'test output' and a newline
|
||||
expectedOutput := "test output\n"
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output '%s', got '%s'", expectedOutput, output)
|
||||
}
|
||||
|
||||
// Verify output is not base64 encoded
|
||||
if strings.Contains(output, "base64") ||
|
||||
(len(output) > 0 && output[0] == 'e' && strings.ContainsAny(output, "+/=")) {
|
||||
t.Error("Output appears to be base64 encoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
@@ -176,8 +178,8 @@ func (b *BrowserSearch) Execute(ctx context.Context, args map[string]any) (any,
|
||||
|
||||
func (b *Browser) buildSearchResultsPageCollection(query string, results *WebSearchResponse) *responses.Page {
|
||||
page := &responses.Page{
|
||||
URL: fmt.Sprintf("%s", "search_results_"+query),
|
||||
Title: fmt.Sprintf("%s", query),
|
||||
URL: "search_results_" + query,
|
||||
Title: query,
|
||||
Links: make(map[int]string),
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
@@ -499,7 +501,6 @@ func (b *BrowserOpen) Schema() map[string]any {
|
||||
}
|
||||
|
||||
func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, string, error) {
|
||||
|
||||
// Get cursor parameter first
|
||||
cursor := -1
|
||||
if c, ok := args["cursor"].(float64); ok {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/auth"
|
||||
)
|
||||
|
||||
// CrawlContent represents the content of a crawled page
|
||||
@@ -54,6 +49,7 @@ func (g *BrowserCrawler) Name() string {
|
||||
func (g *BrowserCrawler) Description() string {
|
||||
return "Crawl and extract text content from web pages"
|
||||
}
|
||||
|
||||
func (g *BrowserCrawler) Prompt() string {
|
||||
return `When you need to read content from web pages, use the get_webpage tool. Simply provide the URLs you want to read and I'll fetch their content for you.
|
||||
|
||||
@@ -77,11 +73,6 @@ func (g *BrowserCrawler) Schema() map[string]any {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of URLs to crawl and extract content from"
|
||||
},
|
||||
"latest": {
|
||||
"type": "boolean",
|
||||
"description": " Needs up to date and latest information (default: false)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["urls"]
|
||||
@@ -94,7 +85,6 @@ func (g *BrowserCrawler) Schema() map[string]any {
|
||||
}
|
||||
|
||||
func (g *BrowserCrawler) Execute(ctx context.Context, args map[string]any) (*CrawlResponse, error) {
|
||||
// Extract and validate URLs
|
||||
urlsRaw, ok := args["urls"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("urls parameter is required and must be an array of strings")
|
||||
@@ -111,86 +101,36 @@ func (g *BrowserCrawler) Execute(ctx context.Context, args map[string]any) (*Cra
|
||||
return nil, fmt.Errorf("at least one URL is required")
|
||||
}
|
||||
|
||||
latest, _ := args["latest"].(bool)
|
||||
|
||||
// Perform the web crawling
|
||||
return g.performWebCrawl(ctx, urls, latest)
|
||||
return g.performWebCrawl(ctx, urls)
|
||||
}
|
||||
|
||||
// performWebCrawl handles the actual HTTP request to ollama.com crawl API
|
||||
func (g *BrowserCrawler) performWebCrawl(ctx context.Context, urls []string, latest bool) (*CrawlResponse, error) {
|
||||
// Prepare the request body matching the API format
|
||||
reqBody := map[string]any{
|
||||
"urls": urls,
|
||||
"text": true,
|
||||
"extras": map[string]any{
|
||||
"links": 1,
|
||||
},
|
||||
"livecrawl": "fallback",
|
||||
}
|
||||
func (g *BrowserCrawler) performWebCrawl(ctx context.Context, urls []string) (*CrawlResponse, error) {
|
||||
result := &CrawlResponse{Results: make(map[string][]CrawlResult, len(urls))}
|
||||
|
||||
if latest {
|
||||
reqBody["livecrawl"] = "always"
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
crawlURL, err := url.Parse("https://ollama.com/api/tools/webcrawl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse crawl URL: %w", err)
|
||||
}
|
||||
|
||||
// Add timestamp for signing
|
||||
query := crawlURL.Query()
|
||||
query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
|
||||
var signature string
|
||||
|
||||
crawlURL.RawQuery = query.Encode()
|
||||
|
||||
// Sign the request data (method + URI)
|
||||
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, crawlURL.RequestURI())
|
||||
signature, err = auth.Sign(ctx, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", crawlURL.String(), bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if signature != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature))
|
||||
}
|
||||
|
||||
// Make the request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute crawl request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read and parse response
|
||||
var result CrawlResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Check for error response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errMsg := "unknown error"
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
errMsg = "crawl service unavailable - API key may not be configured"
|
||||
for _, targetURL := range urls {
|
||||
fetchResp, err := performWebFetch(ctx, targetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web_fetch failed for %q: %w", targetURL, err)
|
||||
}
|
||||
return nil, fmt.Errorf("crawl API error (status %d): %s", resp.StatusCode, errMsg)
|
||||
|
||||
links := make([]CrawlLink, 0, len(fetchResp.Links))
|
||||
for _, link := range fetchResp.Links {
|
||||
links = append(links, CrawlLink{URL: link, Href: link})
|
||||
}
|
||||
|
||||
snippet := truncateString(fetchResp.Content, 400)
|
||||
|
||||
result.Results[targetURL] = []CrawlResult{{
|
||||
Title: fetchResp.Title,
|
||||
URL: targetURL,
|
||||
Content: CrawlContent{
|
||||
Snippet: snippet,
|
||||
FullText: fetchResp.Content,
|
||||
},
|
||||
Extras: CrawlExtras{Links: links},
|
||||
}}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -29,7 +30,7 @@ func TestBrowser_Scroll_AppendsOnlyPageStack(t *testing.T) {
|
||||
|
||||
bo := NewBrowserOpen(b)
|
||||
// Scroll without id — should push only to PageStack
|
||||
_, _, err := bo.Execute(context.TODO(), map[string]any{"loc": float64(1), "num_lines": float64(1)})
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"loc": float64(1), "num_lines": float64(1)})
|
||||
if err != nil {
|
||||
t.Fatalf("scroll execute failed: %v", err)
|
||||
}
|
||||
@@ -51,7 +52,7 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
initialStackLen := len(b.state.Data.PageStack)
|
||||
initialMapLen := len(b.state.Data.URLToPage)
|
||||
|
||||
_, _, err := bo.Execute(context.TODO(), map[string]any{"id": p.URL})
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": p.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("open cached execute failed: %v", err)
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func TestBrowserOpen_LinkId_UsesCacheAndAppends(t *testing.T) {
|
||||
initialMapLen := len(b.state.Data.URLToPage)
|
||||
|
||||
bo := NewBrowserOpen(b)
|
||||
_, _, err := bo.Execute(context.TODO(), map[string]any{"id": float64(0)})
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": float64(0)})
|
||||
if err != nil {
|
||||
t.Fatalf("open by link id failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/auth"
|
||||
)
|
||||
|
||||
// WebSearchContent represents the content of a search result
|
||||
@@ -85,7 +82,6 @@ func (w *BrowserWebSearch) Schema() map[string]any {
|
||||
}
|
||||
|
||||
func (w *BrowserWebSearch) Execute(ctx context.Context, args map[string]any) (any, error) {
|
||||
// Extract and validate queries
|
||||
queriesRaw, ok := args["queries"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("queries parameter is required and must be an array of strings")
|
||||
@@ -102,83 +98,46 @@ func (w *BrowserWebSearch) Execute(ctx context.Context, args map[string]any) (an
|
||||
return nil, fmt.Errorf("at least one query is required")
|
||||
}
|
||||
|
||||
// Get optional parameters
|
||||
maxResults := 5
|
||||
if mr, ok := args["max_results"].(int); ok {
|
||||
maxResults = mr
|
||||
}
|
||||
|
||||
// Perform the web search
|
||||
return w.performWebSearch(ctx, queries, maxResults)
|
||||
}
|
||||
|
||||
// performWebSearch handles the actual HTTP request to ollama.com search API
|
||||
func (w *BrowserWebSearch) performWebSearch(ctx context.Context, queries []string, maxResults int) (*WebSearchResponse, error) {
|
||||
// Prepare the request body
|
||||
reqBody := map[string]any{
|
||||
"queries": queries,
|
||||
"max_results": maxResults,
|
||||
}
|
||||
response := &WebSearchResponse{Results: make(map[string][]WebSearchResult, len(queries))}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
searchURL, err := url.Parse("https://ollama.com/api/tools/websearch")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search URL: %w", err)
|
||||
}
|
||||
|
||||
// Add timestamp for signing
|
||||
query := searchURL.Query()
|
||||
query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
|
||||
var signature string
|
||||
|
||||
searchURL.RawQuery = query.Encode()
|
||||
|
||||
// Sign the request data (method + URI)
|
||||
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, searchURL.RequestURI())
|
||||
signature, err = auth.Sign(ctx, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", searchURL.String(), bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if signature != "" {
|
||||
req.Header.Set("Authorization", signature)
|
||||
}
|
||||
|
||||
// Make the request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute search request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read and parse response
|
||||
var result WebSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Check for error response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errMsg := "unknown error"
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
errMsg = "search service unavailable - API key may not be configured"
|
||||
for _, query := range queries {
|
||||
searchResp, err := performWebSearch(ctx, query, maxResults)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web_search failed for %q: %w", query, err)
|
||||
}
|
||||
return nil, fmt.Errorf("search API error (status %d): %s", resp.StatusCode, errMsg)
|
||||
|
||||
converted := make([]WebSearchResult, 0, len(searchResp.Results))
|
||||
for _, item := range searchResp.Results {
|
||||
converted = append(converted, WebSearchResult{
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Content: WebSearchContent{
|
||||
Snippet: truncateString(item.Content, 400),
|
||||
FullText: item.Content,
|
||||
},
|
||||
Metadata: WebSearchMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
response.Results[query] = converted
|
||||
}
|
||||
|
||||
// Return the results directly without caching
|
||||
return &result, nil
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func truncateString(input string, limit int) string {
|
||||
if limit <= 0 || len(input) <= limit {
|
||||
return input
|
||||
}
|
||||
return input[:limit]
|
||||
}
|
||||
|
||||
35
app/tools/cloud_policy.go
Normal file
35
app/tools/cloud_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
)
|
||||
|
||||
// ensureCloudEnabledForTool checks cloud policy from the connected Ollama server.
|
||||
// If policy cannot be determined, this fails closed and blocks the operation.
|
||||
func ensureCloudEnabledForTool(ctx context.Context, operation string) error {
|
||||
// Reuse shared message formatting; policy evaluation is still done via
|
||||
// the connected server's /api/status endpoint below.
|
||||
disabledMessage := internalcloud.DisabledError(operation)
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return errors.New(disabledMessage + " (unable to verify server cloud policy)")
|
||||
}
|
||||
|
||||
status, err := client.CloudStatusExperimental(ctx)
|
||||
if err != nil {
|
||||
return errors.New(disabledMessage + " (unable to verify server cloud policy)")
|
||||
}
|
||||
|
||||
if status.Cloud.Disabled {
|
||||
return errors.New(disabledMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
73
app/tools/cloud_policy_test.go
Normal file
73
app/tools/cloud_policy_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureCloudEnabledForTool(t *testing.T) {
|
||||
const op = "web search is unavailable"
|
||||
const disabledPrefix = "ollama cloud is disabled: web search is unavailable"
|
||||
|
||||
t.Run("enabled allows tool execution", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"cloud":{"disabled":false,"source":"none"}}`))
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||
|
||||
if err := ensureCloudEnabledForTool(context.Background(), op); err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled blocks tool execution", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"cloud":{"disabled":true,"source":"config"}}`))
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||
|
||||
err := ensureCloudEnabledForTool(context.Background(), op)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); got != disabledPrefix {
|
||||
t.Fatalf("unexpected error: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status unavailable fails closed", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||
|
||||
err := ensureCloudEnabledForTool(context.Background(), op)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, disabledPrefix) {
|
||||
t.Fatalf("expected disabled prefix, got %q", got)
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "unable to verify server cloud policy") {
|
||||
t.Fatalf("expected verification failure detail, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetWebpage_Name(t *testing.T) {
|
||||
tool := &BrowserCrawler{}
|
||||
if name := tool.Name(); name != "get_webpage" {
|
||||
t.Errorf("Expected name 'get_webpage', got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebpage_Description(t *testing.T) {
|
||||
tool := &BrowserCrawler{}
|
||||
desc := tool.Description()
|
||||
if desc == "" {
|
||||
t.Error("Description should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebpage_Schema(t *testing.T) {
|
||||
tool := &BrowserCrawler{}
|
||||
schema := tool.Schema()
|
||||
if schema == nil {
|
||||
t.Error("Schema should not be nil")
|
||||
}
|
||||
|
||||
// Check if schema has required properties
|
||||
if schema["type"] != "object" {
|
||||
t.Error("Schema type should be 'object'")
|
||||
}
|
||||
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Error("Schema should have properties")
|
||||
}
|
||||
|
||||
// Check if urls property exists
|
||||
if _, ok := properties["urls"]; !ok {
|
||||
t.Error("Schema should have 'urls' property")
|
||||
}
|
||||
|
||||
// Check if required field exists
|
||||
required, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
t.Error("Schema should have 'required' field")
|
||||
}
|
||||
|
||||
// Check if urls is in required
|
||||
foundUrls := false
|
||||
for _, req := range required {
|
||||
if req == "urls" {
|
||||
foundUrls = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundUrls {
|
||||
t.Error("'urls' should be in required fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebpage_Execute_InvalidInput(t *testing.T) {
|
||||
tool := &BrowserCrawler{}
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "missing urls",
|
||||
input: map[string]any{},
|
||||
errContains: "urls parameter is required",
|
||||
},
|
||||
{
|
||||
name: "empty urls array",
|
||||
input: map[string]any{
|
||||
"urls": []any{},
|
||||
},
|
||||
errContains: "at least one URL is required",
|
||||
},
|
||||
{
|
||||
name: "invalid urls type",
|
||||
input: map[string]any{
|
||||
"urls": "not an array",
|
||||
},
|
||||
errContains: "urls parameter is required and must be an array",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tool.Execute(ctx, tt.input)
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
} else if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ledongthuc/pdf"
|
||||
)
|
||||
|
||||
// FileInfo represents information about a single file or directory
|
||||
type FileInfo struct {
|
||||
// BasePath string `json:"base_path"`
|
||||
RelPath string `json:"rel_path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// FileListResult represents the result of a directory listing operation
|
||||
type FileListResult struct {
|
||||
BasePath string `json:"base_path"`
|
||||
Files []FileInfo `json:"files"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// FileReadResult represents the result of a file read operation
|
||||
type FileReadResult struct {
|
||||
Path string `json:"path"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
LinesRead int `json:"lines_read"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// FileWriteResult represents the result of a file write operation
|
||||
type FileWriteResult struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Written int `json:"written"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Modified int64 `json:"modified,omitempty"`
|
||||
}
|
||||
|
||||
// FileReader implements the file reading functionality
|
||||
type FileReader struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func (f *FileReader) SetWorkingDir(dir string) {
|
||||
f.workingDir = dir
|
||||
}
|
||||
|
||||
func (f *FileReader) Name() string {
|
||||
return "file_read"
|
||||
}
|
||||
|
||||
func (f *FileReader) Description() string {
|
||||
return "Read the contents of a file from the file system"
|
||||
}
|
||||
|
||||
func (f *FileReader) Prompt() string {
|
||||
// TODO: read iteratively in agent mode, full in single shot - control with prompt?
|
||||
return `Use the file_read tool to read the contents of a file using the path parameter. read_full is false by default and will return the first 100 lines of the file, if the user requires more information about the file, set read_full to true`
|
||||
}
|
||||
|
||||
func (f *FileReader) Schema() map[string]any {
|
||||
schemaBytes := []byte(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the file to read"
|
||||
},
|
||||
"read_full": {
|
||||
"type": "boolean",
|
||||
"description": "returns the first 100 lines of the file when set to false (default: false)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}`)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
|
||||
return nil
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func (f *FileReader) Execute(ctx context.Context, args map[string]any) (any, error) {
|
||||
fmt.Println("file_read tool called", args)
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("path parameter is required and must be a string")
|
||||
}
|
||||
|
||||
// If path is not absolute and working directory is set, make it relative to working directory
|
||||
if !filepath.IsAbs(path) && f.workingDir != "" {
|
||||
path = filepath.Join(f.workingDir, path)
|
||||
}
|
||||
|
||||
// Security: Clean and validate the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return nil, fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
// Get max size limit
|
||||
maxSize := int64(1024 * 1024) // 1MB default
|
||||
if ms, ok := args["max_size"]; ok {
|
||||
switch v := ms.(type) {
|
||||
case float64:
|
||||
maxSize = int64(v)
|
||||
case int:
|
||||
maxSize = int64(v)
|
||||
case int64:
|
||||
maxSize = v
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists and get info
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", cleanPath)
|
||||
}
|
||||
return nil, fmt.Errorf("error accessing file: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("path is a directory, not a file: %s", cleanPath)
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if info.Size() > maxSize {
|
||||
return nil, fmt.Errorf("file too large (%d bytes), maximum allowed: %d bytes", info.Size(), maxSize)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(cleanPath), ".pdf") {
|
||||
return f.readPDFFile(cleanPath, args)
|
||||
}
|
||||
|
||||
// Check read_full parameter
|
||||
readFull := false // default to false
|
||||
if rf, ok := args["read_full"]; ok {
|
||||
readFull, _ = rf.(bool)
|
||||
}
|
||||
|
||||
// Open and read the file
|
||||
file, err := os.Open(cleanPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file content
|
||||
scanner := bufio.NewScanner(file)
|
||||
var lines []string
|
||||
totalLines := 0
|
||||
|
||||
// Read content, keeping track of total lines but only storing up to 100 if !readFull
|
||||
for scanner.Scan() {
|
||||
totalLines++
|
||||
if readFull || totalLines <= 100 {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
content := strings.Join(lines, "\n")
|
||||
|
||||
return &FileReadResult{
|
||||
Path: cleanPath,
|
||||
LinesRead: len(lines),
|
||||
TotalLines: totalLines,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readPDFFile extracts text from a PDF file
|
||||
func (f *FileReader) readPDFFile(cleanPath string, args map[string]any) (any, error) {
|
||||
// Open the PDF file
|
||||
pdfFile, r, err := pdf.Open(cleanPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening PDF: %w", err)
|
||||
}
|
||||
defer pdfFile.Close()
|
||||
|
||||
// Get total number of pages
|
||||
totalPages := r.NumPage()
|
||||
|
||||
// Check read_full parameter - for PDFs, this controls whether to read all pages
|
||||
readFull := false
|
||||
if rf, ok := args["read_full"]; ok {
|
||||
readFull, _ = rf.(bool)
|
||||
}
|
||||
|
||||
// Extract text from pages
|
||||
var allText strings.Builder
|
||||
maxPages := 10 // Default to first 10 pages if not read_full
|
||||
if readFull {
|
||||
maxPages = totalPages
|
||||
}
|
||||
|
||||
linesExtracted := 0
|
||||
for pageNum := 1; pageNum <= totalPages && pageNum <= maxPages; pageNum++ {
|
||||
// Get page
|
||||
page := r.Page(pageNum)
|
||||
if page.V.IsNull() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the built-in GetPlainText method which handles text extraction better
|
||||
pageText, err := page.GetPlainText(nil)
|
||||
if err != nil {
|
||||
// If GetPlainText fails, fall back to manual extraction
|
||||
pageText = f.extractTextFromPage(page)
|
||||
}
|
||||
|
||||
pageText = strings.TrimSpace(pageText)
|
||||
if pageText != "" {
|
||||
if allText.Len() > 0 {
|
||||
allText.WriteString("\n\n")
|
||||
}
|
||||
allText.WriteString(fmt.Sprintf("--- Page %d ---\n", pageNum))
|
||||
allText.WriteString(pageText)
|
||||
|
||||
// Count lines for reporting
|
||||
linesExtracted += strings.Count(pageText, "\n") + 1
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(allText.String())
|
||||
|
||||
// If no text was extracted, return a helpful message
|
||||
if content == "" {
|
||||
content = "[PDF file contains no extractable text - it may contain only images or use complex encoding]"
|
||||
linesExtracted = 1
|
||||
}
|
||||
|
||||
return &FileReadResult{
|
||||
Path: cleanPath,
|
||||
LinesRead: linesExtracted,
|
||||
TotalLines: totalPages, // For PDFs, we report pages as "lines"
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractTextFromPage extracts text from a single PDF page
|
||||
func (f *FileReader) extractTextFromPage(page pdf.Page) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Get page contents
|
||||
contents := page.Content()
|
||||
|
||||
// Group text elements that appear to be part of the same word/line
|
||||
var currentLine strings.Builder
|
||||
lastX := -1.0
|
||||
|
||||
for i, t := range contents.Text {
|
||||
// Skip empty text
|
||||
if t.S == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this text element is on a new line or far from the previous one
|
||||
// If X position is significantly different or we've reset to the beginning, it's likely a new word
|
||||
if lastX >= 0 && (t.X < lastX-10 || t.X > lastX+50) {
|
||||
// Add the accumulated line to buffer with a space
|
||||
if currentLine.Len() > 0 {
|
||||
buf.WriteString(currentLine.String())
|
||||
buf.WriteString(" ")
|
||||
currentLine.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Add the text without extra spaces
|
||||
currentLine.WriteString(t.S)
|
||||
lastX = t.X
|
||||
|
||||
// Check if next element exists and has significantly different Y position (new line)
|
||||
if i+1 < len(contents.Text) && contents.Text[i+1].Y > t.Y+5 {
|
||||
if currentLine.Len() > 0 {
|
||||
buf.WriteString(currentLine.String())
|
||||
buf.WriteString("\n")
|
||||
currentLine.Reset()
|
||||
lastX = -1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if currentLine.Len() > 0 {
|
||||
buf.WriteString(currentLine.String())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
|
||||
// FileList implements the directory listing functionality
|
||||
type FileList struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func (f *FileList) SetWorkingDir(dir string) {
|
||||
f.workingDir = dir
|
||||
}
|
||||
|
||||
func (f *FileList) Name() string {
|
||||
return "file_list"
|
||||
}
|
||||
|
||||
func (f *FileList) Description() string {
|
||||
return "List the contents of a directory"
|
||||
}
|
||||
|
||||
func (f *FileList) Prompt() string {
|
||||
return `Use the file_list tool to list the contents of a directory using the path parameter`
|
||||
}
|
||||
|
||||
func (f *FileList) Schema() map[string]any {
|
||||
schemaBytes := []byte(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the directory to list (default: current directory)",
|
||||
"default": "."
|
||||
},
|
||||
"show_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to show hidden files (starting with .)",
|
||||
"default": false
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "How many directory levels deep to list (default: 1)",
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}`)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
|
||||
return nil
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func (f *FileList) Execute(ctx context.Context, args map[string]any) (any, error) {
|
||||
path := "."
|
||||
if p, ok := args["path"].(string); ok {
|
||||
path = p
|
||||
}
|
||||
|
||||
// If path is not absolute and working directory is set, make it relative to working directory
|
||||
if !filepath.IsAbs(path) && f.workingDir != "" {
|
||||
path = filepath.Join(f.workingDir, path)
|
||||
}
|
||||
|
||||
// Security: Clean and validate the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return nil, fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
// Get optional parameters
|
||||
showHidden := false
|
||||
if sh, ok := args["show_hidden"].(bool); ok {
|
||||
showHidden = sh
|
||||
}
|
||||
|
||||
maxDepth := 1
|
||||
if md, ok := args["depth"].(float64); ok {
|
||||
maxDepth = int(md)
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("directory does not exist: %s", cleanPath)
|
||||
}
|
||||
return nil, fmt.Errorf("error accessing directory: %w", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("path is not a directory: %s", cleanPath)
|
||||
}
|
||||
|
||||
var files []FileInfo
|
||||
|
||||
files, err = f.listRecursive(cleanPath, showHidden, maxDepth, 0)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FileListResult{
|
||||
BasePath: cleanPath,
|
||||
Files: files,
|
||||
Count: len(files),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FileList) listDirectory(path string, showHidden bool) ([]FileInfo, error) {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading directory: %w", err)
|
||||
}
|
||||
|
||||
var files []FileInfo
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
||||
// Skip hidden files if not requested
|
||||
if !showHidden && strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
fileInfo := FileInfo{
|
||||
RelPath: name,
|
||||
IsDir: entry.IsDir(),
|
||||
}
|
||||
|
||||
files = append(files, fileInfo)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *FileList) listRecursive(path string, showHidden bool, maxDepth, currentDepth int) ([]FileInfo, error) {
|
||||
if currentDepth >= maxDepth {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
files, err := f.listDirectory(path, showHidden)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allFiles []FileInfo
|
||||
for _, file := range files {
|
||||
// For the first level, use the file name as is
|
||||
// For deeper levels, join with parent directory
|
||||
if currentDepth != 0 {
|
||||
// Get the relative part of the path by removing the base path
|
||||
rel, err := filepath.Rel(filepath.Dir(path), path)
|
||||
if err == nil {
|
||||
file.RelPath = filepath.Join(rel, file.RelPath)
|
||||
}
|
||||
}
|
||||
allFiles = append(allFiles, file)
|
||||
|
||||
if file.IsDir {
|
||||
subFiles, err := f.listRecursive(filepath.Join(path, file.RelPath), showHidden, maxDepth, currentDepth+1)
|
||||
if err != nil {
|
||||
continue // Skip directories we can't read
|
||||
}
|
||||
allFiles = append(allFiles, subFiles...)
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// FileWriter implements the file writing functionality
|
||||
// TODO(parthsareen): max file size limit
|
||||
type FileWriter struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func (f *FileWriter) SetWorkingDir(dir string) {
|
||||
f.workingDir = dir
|
||||
}
|
||||
|
||||
func (f *FileWriter) Name() string {
|
||||
return "file_write"
|
||||
}
|
||||
|
||||
func (f *FileWriter) Description() string {
|
||||
return "Write content to a file on the file system"
|
||||
}
|
||||
|
||||
func (f *FileWriter) Prompt() string {
|
||||
return `Use the file_write tool to write content to a file using the path parameter`
|
||||
}
|
||||
|
||||
func (f *FileWriter) Schema() map[string]any {
|
||||
schemaBytes := []byte(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the file to write"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The content to write to the file"
|
||||
},
|
||||
"append": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to append to the file instead of overwriting (default: false)",
|
||||
"default": false
|
||||
},
|
||||
"create_dirs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to create parent directories if they don't exist (default: false)",
|
||||
"default": false
|
||||
},
|
||||
"max_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum content size to write in bytes (default: 1MB)",
|
||||
"default": 1024 * 1024
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
}`)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
|
||||
return nil
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func (f *FileWriter) Execute(ctx context.Context, args map[string]any) (any, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("path parameter is required and must be a string")
|
||||
}
|
||||
|
||||
// If path is not absolute and working directory is set, make it relative to working directory
|
||||
if !filepath.IsAbs(path) && f.workingDir != "" {
|
||||
path = filepath.Join(f.workingDir, path)
|
||||
}
|
||||
|
||||
// Extract required parameters
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("content parameter is required and must be a string")
|
||||
}
|
||||
|
||||
// Get optional parameters with defaults
|
||||
append := true // Always append by default
|
||||
if a, ok := args["append"].(bool); ok && !a {
|
||||
return nil, fmt.Errorf("overwriting existing files is not allowed - must use append mode")
|
||||
}
|
||||
|
||||
createDirs := false
|
||||
if cd, ok := args["create_dirs"].(bool); ok {
|
||||
createDirs = cd
|
||||
}
|
||||
|
||||
maxSize := int64(1024 * 1024) // 1MB default
|
||||
if ms, ok := args["max_size"].(float64); ok {
|
||||
maxSize = int64(ms)
|
||||
}
|
||||
|
||||
// Security: Clean and validate the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return nil, fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
// Check content size
|
||||
if int64(len(content)) > maxSize {
|
||||
return nil, fmt.Errorf("content too large (%d bytes), maximum allowed: %d bytes", len(content), maxSize)
|
||||
}
|
||||
|
||||
// Create parent directories if requested
|
||||
if createDirs {
|
||||
dir := filepath.Dir(cleanPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create parent directories: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists - if it does, we must append
|
||||
fileInfo, err := os.Stat(cleanPath)
|
||||
if err == nil && fileInfo.Size() > 0 {
|
||||
// File exists and has content
|
||||
if !append {
|
||||
return nil, fmt.Errorf("file %s already exists - cannot overwrite, must use append mode", cleanPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Open file in append mode
|
||||
flag := os.O_WRONLY | os.O_CREATE | os.O_APPEND
|
||||
file, err := os.OpenFile(cleanPath, flag, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening file for writing: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content
|
||||
n, err := file.WriteString(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error writing to file: %w", err)
|
||||
}
|
||||
|
||||
// Get file info for response
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
// Return basic success info if we can't get file stats
|
||||
return &FileWriteResult{
|
||||
Path: cleanPath,
|
||||
Written: n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &FileWriteResult{
|
||||
Path: cleanPath,
|
||||
Size: info.Size(),
|
||||
Written: n,
|
||||
Mode: info.Mode().String(),
|
||||
Modified: info.ModTime().Unix(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
@@ -66,15 +68,28 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
|
||||
reqBody := FetchRequest{URL: urlStr}
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
func performWebFetch(ctx context.Context, targetURL string) (*FetchResponse, error) {
|
||||
if err := ensureCloudEnabledForTool(ctx, "web fetch is unavailable"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := FetchRequest{URL: targetURL}
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to marshal request body: %w", err)
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
crawlURL, err := url.Parse("https://ollama.com/api/web_fetch")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse fetch URL: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse fetch URL: %w", err)
|
||||
}
|
||||
|
||||
query := crawlURL.Query()
|
||||
@@ -84,12 +99,12 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, crawlURL.RequestURI())
|
||||
signature, err := auth.Sign(ctx, data)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to sign request: %w", err)
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", crawlURL.String(), bytes.NewBuffer(jsonBody))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, crawlURL.String(), bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -100,18 +115,18 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to execute fetch request: %w", err)
|
||||
return nil, fmt.Errorf("failed to execute fetch request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("fetch API error (status %d)", resp.StatusCode)
|
||||
return nil, fmt.Errorf("fetch API error (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result FetchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, "", nil
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
@@ -67,7 +69,6 @@ func (g *WebSearch) Schema() map[string]any {
|
||||
}
|
||||
|
||||
func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, string, error) {
|
||||
|
||||
rawQuery, ok := args["query"]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("query parameter is required")
|
||||
@@ -83,15 +84,29 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
maxResults = int(v)
|
||||
}
|
||||
|
||||
reqBody := SearchRequest{Query: queryStr, MaxResults: maxResults}
|
||||
result, err := performWebSearch(ctx, queryStr, maxResults)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
func performWebSearch(ctx context.Context, query string, maxResults int) (*SearchResponse, error) {
|
||||
if err := ensureCloudEnabledForTool(ctx, "web search is unavailable"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := SearchRequest{Query: query, MaxResults: maxResults}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to marshal request body: %w", err)
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
searchURL, err := url.Parse("https://ollama.com/api/web_search")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse search URL: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse search URL: %w", err)
|
||||
}
|
||||
|
||||
q := searchURL.Query()
|
||||
@@ -101,14 +116,15 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, searchURL.RequestURI())
|
||||
signature, err := auth.Sign(ctx, data)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to sign request: %w", err)
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, searchURL.String(), bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if signature != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature))
|
||||
}
|
||||
@@ -116,18 +132,18 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to execute search request: %w", err)
|
||||
return nil, fmt.Errorf("failed to execute search request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("search API error (status %d)", resp.StatusCode)
|
||||
return nil, fmt.Errorf("search API error (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result SearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, "", nil
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package not
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package not
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package not_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
@@ -37,7 +39,6 @@ func (s *Server) appHandler() http.Handler {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(data))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
"@chromatic-com/storybook",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-onboarding",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-vitest",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
reactDocgenTypescriptOptions: {
|
||||
tsconfigPath: "../tsconfig.stories.json",
|
||||
},
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import "../src/index.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: "todo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
import { setProjectAnnotations } from "@storybook/react-vite";
|
||||
import * as projectAnnotations from "./preview";
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
@@ -217,14 +217,12 @@ export class Model {
|
||||
model: string;
|
||||
digest?: string;
|
||||
modified_at?: Time;
|
||||
needs_download?: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.model = source["model"];
|
||||
this.digest = source["digest"];
|
||||
this.modified_at = this.convertValues(source["modified_at"], Time);
|
||||
this.needs_download = source["needs_download"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -291,10 +289,12 @@ export class InferenceCompute {
|
||||
}
|
||||
export class InferenceComputeResponse {
|
||||
inferenceComputes: InferenceCompute[];
|
||||
defaultContextLength: number;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.inferenceComputes = this.convertValues(source["inferenceComputes"], InferenceCompute);
|
||||
this.defaultContextLength = source["defaultContextLength"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -408,13 +408,13 @@ export class Settings {
|
||||
Tools: boolean;
|
||||
WorkingDir: string;
|
||||
ContextLength: number;
|
||||
AirplaneMode: boolean;
|
||||
TurboEnabled: boolean;
|
||||
WebSearchEnabled: boolean;
|
||||
ThinkEnabled: boolean;
|
||||
ThinkLevel: string;
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
AutoUpdateEnabled: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
@@ -426,13 +426,13 @@ export class Settings {
|
||||
this.Tools = source["Tools"];
|
||||
this.WorkingDir = source["WorkingDir"];
|
||||
this.ContextLength = source["ContextLength"];
|
||||
this.AirplaneMode = source["AirplaneMode"];
|
||||
this.TurboEnabled = source["TurboEnabled"];
|
||||
this.WebSearchEnabled = source["WebSearchEnabled"];
|
||||
this.ThinkEnabled = source["ThinkEnabled"];
|
||||
this.ThinkLevel = source["ThinkLevel"];
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
}
|
||||
}
|
||||
export class SettingsResponse {
|
||||
@@ -471,26 +471,24 @@ export class HealthResponse {
|
||||
}
|
||||
export class User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarURL: string;
|
||||
plan: string;
|
||||
bio: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
overThreshold: boolean;
|
||||
name: string;
|
||||
bio?: string;
|
||||
avatarurl?: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
plan?: string;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.email = source["email"];
|
||||
this.avatarURL = source["avatarURL"];
|
||||
this.plan = source["plan"];
|
||||
this.name = source["name"];
|
||||
this.bio = source["bio"];
|
||||
this.firstName = source["firstName"];
|
||||
this.lastName = source["lastName"];
|
||||
this.overThreshold = source["overThreshold"];
|
||||
this.avatarurl = source["avatarurl"];
|
||||
this.firstname = source["firstname"];
|
||||
this.lastname = source["lastname"];
|
||||
this.plan = source["plan"];
|
||||
}
|
||||
}
|
||||
export class Attachment {
|
||||
|
||||
@@ -11,18 +11,31 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
// Initialize webview API object if individual functions are available
|
||||
if (typeof window.selectFile === "function") {
|
||||
window.webview = {
|
||||
selectFile: function () {
|
||||
return new Promise((resolve) => {
|
||||
window.__selectFileCallback = (data) => {
|
||||
window.__selectFileCallback = null;
|
||||
resolve(data); // Returns file data or null if cancelled
|
||||
};
|
||||
window.selectFile();
|
||||
});
|
||||
},
|
||||
// Add selectFiles method if available
|
||||
if (typeof window.selectFiles === "function") {
|
||||
window.webview = window.webview || {};
|
||||
|
||||
// Single file selection (returns first file or null)
|
||||
window.webview.selectFile = function () {
|
||||
return new Promise((resolve) => {
|
||||
window.__selectFilesCallback = (data) => {
|
||||
window.__selectFilesCallback = null;
|
||||
// For single file, return first file or null
|
||||
resolve(data && data.length > 0 ? data[0] : null);
|
||||
};
|
||||
window.selectFiles();
|
||||
});
|
||||
};
|
||||
|
||||
// Multiple file selection (returns array or null)
|
||||
window.webview.selectMultipleFiles = function () {
|
||||
return new Promise((resolve) => {
|
||||
window.__selectFilesCallback = (data) => {
|
||||
window.__selectFilesCallback = null;
|
||||
resolve(data); // Returns array of files or null if cancelled
|
||||
};
|
||||
window.selectFiles();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
1528
app/ui/app/package-lock.json
generated
1528
app/ui/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"framer-motion": "^12.17.0",
|
||||
"katex": "^0.16.22",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
"ollama": "^0.6.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
@@ -33,6 +34,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"streamdown": "^1.4.0",
|
||||
"unist-builder": "^4.0.0",
|
||||
"unist-util-parents": "^3.0.0"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
ChatEvent,
|
||||
DownloadEvent,
|
||||
ErrorEvent,
|
||||
ModelsResponse,
|
||||
InferenceCompute,
|
||||
InferenceComputeResponse,
|
||||
ModelCapabilitiesResponse,
|
||||
Model,
|
||||
@@ -14,6 +12,9 @@ import {
|
||||
User,
|
||||
} from "@/gotypes";
|
||||
import { parseJsonlFromResponse } from "./util/jsonl-parsing";
|
||||
import { ollamaClient as ollama } from "./lib/ollama-client";
|
||||
import type { ModelResponse } from "ollama/browser";
|
||||
import { API_BASE, OLLAMA_DOT_COM } from "./lib/config";
|
||||
|
||||
// Extend Model class with utility methods
|
||||
declare module "@/gotypes" {
|
||||
@@ -26,8 +27,11 @@ Model.prototype.isCloud = function (): boolean {
|
||||
return this.model.endsWith("cloud");
|
||||
};
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
|
||||
|
||||
export type CloudStatusSource = "env" | "config" | "both" | "none";
|
||||
export interface CloudStatusResponse {
|
||||
disabled: boolean;
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
@@ -42,44 +46,50 @@ function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
}
|
||||
|
||||
export async function fetchUser(): Promise<User | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData: User = await response.json();
|
||||
return userData;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchConnectUrl(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/connect`, {
|
||||
method: "GET",
|
||||
const response = await fetch(`${API_BASE}/api/me`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch connect URL");
|
||||
if (response.ok) {
|
||||
const userData: User = await response.json();
|
||||
|
||||
if (userData.avatarurl && !userData.avatarurl.startsWith("http")) {
|
||||
userData.avatarurl = `${OLLAMA_DOT_COM}${userData.avatarurl}`;
|
||||
}
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.connect_url;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch user: ${response.status}`);
|
||||
}
|
||||
|
||||
export async function fetchConnectUrl(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE}/api/me`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
return data.signin_url;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch connect URL");
|
||||
}
|
||||
|
||||
export async function disconnectUser(): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/disconnect`, {
|
||||
const response = await fetch(`${API_BASE}/api/signout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -104,38 +114,86 @@ export async function getChat(chatId: string): Promise<ChatResponse> {
|
||||
}
|
||||
|
||||
export async function getModels(query?: string): Promise<Model[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.append("q", query);
|
||||
}
|
||||
try {
|
||||
const { models: modelsResponse } = await ollama.list();
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/models?${params.toString()}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
||||
}
|
||||
let models: Model[] = modelsResponse
|
||||
.filter((m: ModelResponse) => {
|
||||
const families = m.details?.families;
|
||||
|
||||
const data = await response.json();
|
||||
const modelsResponse = new ModelsResponse(data);
|
||||
return modelsResponse.models || [];
|
||||
if (!families || families.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isBertOnly = families.every((family: string) =>
|
||||
family.toLowerCase().includes("bert"),
|
||||
);
|
||||
|
||||
return !isBertOnly;
|
||||
})
|
||||
.map((m: ModelResponse) => {
|
||||
// Remove the latest tag from the returned model
|
||||
const modelName = m.name.replace(/:latest$/, "");
|
||||
|
||||
return new Model({
|
||||
model: modelName,
|
||||
digest: m.digest,
|
||||
modified_at: m.modified_at ? new Date(m.modified_at) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Filter by query if provided
|
||||
if (query) {
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
|
||||
const filteredModels = models.filter((m: Model) => {
|
||||
return m.model.toLowerCase().startsWith(normalizedQuery);
|
||||
});
|
||||
|
||||
let exactMatch = false;
|
||||
for (const m of filteredModels) {
|
||||
if (m.model.toLowerCase() === normalizedQuery) {
|
||||
exactMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (existsUpstream) {
|
||||
filteredModels.push(new Model({ model: query }));
|
||||
}
|
||||
}
|
||||
|
||||
models = filteredModels;
|
||||
}
|
||||
|
||||
return models;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch models: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(
|
||||
modelName: string,
|
||||
): Promise<ModelCapabilitiesResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/model/${encodeURIComponent(modelName)}/capabilities`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch model capabilities: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const showResponse = await ollama.show({ model: modelName });
|
||||
|
||||
const data = await response.json();
|
||||
return new ModelCapabilitiesResponse(data);
|
||||
return new ModelCapabilitiesResponse({
|
||||
capabilities: Array.isArray(showResponse.capabilities)
|
||||
? showResponse.capabilities
|
||||
: [],
|
||||
});
|
||||
} catch (error) {
|
||||
// Model might not be downloaded yet, return empty capabilities
|
||||
console.error(`Failed to get capabilities for ${modelName}:`, error);
|
||||
return new ModelCapabilitiesResponse({ capabilities: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatEventUnion = ChatEvent | DownloadEvent | ErrorEvent;
|
||||
|
||||
export async function* sendMessage(
|
||||
@@ -156,6 +214,11 @@ export async function* sendMessage(
|
||||
data: uint8ArrayToBase64(att.data),
|
||||
}));
|
||||
|
||||
// Send think parameter when it's explicitly set (true, false, or a non-empty string).
|
||||
const shouldSendThink =
|
||||
think !== undefined &&
|
||||
(typeof think === "boolean" || (typeof think === "string" && think !== ""));
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -173,7 +236,7 @@ export async function* sendMessage(
|
||||
web_search: webSearch ?? false,
|
||||
file_tools: fileTools ?? false,
|
||||
...(forceUpdate !== undefined ? { forceUpdate } : {}),
|
||||
...(think !== undefined ? { think } : {}),
|
||||
...(shouldSendThink ? { think } : {}),
|
||||
}),
|
||||
),
|
||||
signal,
|
||||
@@ -227,6 +290,28 @@ export async function updateSettings(settings: Settings): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCloudSetting(
|
||||
enabled: boolean,
|
||||
): Promise<CloudStatusResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/cloud`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || "Failed to update cloud setting");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
disabled: Boolean(data.disabled),
|
||||
source: (data.source as CloudStatusSource) || "none",
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameChat(chatId: string, title: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}/rename`, {
|
||||
method: "PUT",
|
||||
@@ -321,7 +406,7 @@ export async function* pullModel(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInferenceCompute(): Promise<InferenceCompute[]> {
|
||||
export async function getInferenceCompute(): Promise<InferenceComputeResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -330,13 +415,13 @@ export async function getInferenceCompute(): Promise<InferenceCompute[]> {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const inferenceComputeResponse = new InferenceComputeResponse(data);
|
||||
return inferenceComputeResponse.inferenceComputes || [];
|
||||
return new InferenceComputeResponse(data);
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/health`, {
|
||||
// Use the /api/version endpoint as a health check
|
||||
const response = await fetch(`${API_BASE}/api/version`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -345,7 +430,8 @@ export async function fetchHealth(): Promise<boolean> {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.healthy || false;
|
||||
// If we get a version back, the server is healthy
|
||||
return !!data.version;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -354,3 +440,16 @@ export async function fetchHealth(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCloudStatus(): Promise<CloudStatusResponse | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/cloud`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch cloud status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
disabled: Boolean(data.disabled),
|
||||
source: (data.source as CloudStatusSource) || "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,20 @@ import {
|
||||
} from "@/hooks/useChats";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useSelectedModel } from "@/hooks/useSelectedModel";
|
||||
import {
|
||||
useHasVisionCapability,
|
||||
useHasToolsCapability,
|
||||
} from "@/hooks/useModelCapabilities";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { DisplayLogin } from "@/components/DisplayLogin";
|
||||
import { ErrorEvent, Message } from "@/gotypes";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { ThinkButton } from "./ThinkButton";
|
||||
import { ErrorMessage } from "./ErrorMessage";
|
||||
import { processFiles } from "@/utils/fileValidation";
|
||||
import type { ImageData } from "@/types/webview";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export type ThinkingLevel = "low" | "medium" | "high";
|
||||
|
||||
@@ -104,10 +113,14 @@ function ChatForm({
|
||||
const cancelMessage = useCancelMessage();
|
||||
const isDownloading = isDownloadingModel;
|
||||
const { selectedModel } = useSelectedModel();
|
||||
const hasVisionCapability = useHasVisionCapability(selectedModel?.model);
|
||||
const { isAuthenticated, isLoading: isLoadingUser } = useUser();
|
||||
const [loginPromptFeature, setLoginPromptFeature] = useState<
|
||||
"webSearch" | "turbo" | null
|
||||
>(null);
|
||||
const [fileUploadError, setFileUploadError] = useState<ErrorEvent | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleThinkingLevelDropdownToggle = (isOpen: boolean) => {
|
||||
if (
|
||||
@@ -132,19 +145,14 @@ function ChatForm({
|
||||
const {
|
||||
settings: {
|
||||
webSearchEnabled,
|
||||
airplaneMode,
|
||||
thinkEnabled,
|
||||
thinkLevel: settingsThinkLevel,
|
||||
},
|
||||
setSettings,
|
||||
} = useSettings();
|
||||
const { cloudDisabled } = useCloudStatus();
|
||||
|
||||
// current supported models for web search
|
||||
const modelLower = selectedModel?.model.toLowerCase() || "";
|
||||
const supportsWebSearch =
|
||||
modelLower.startsWith("gpt-oss") ||
|
||||
modelLower.startsWith("qwen3") ||
|
||||
modelLower.startsWith("deepseek-v3");
|
||||
const supportsWebSearch = useHasToolsCapability(selectedModel?.model);
|
||||
// Use per-chat thinking level instead of global
|
||||
const thinkLevel: ThinkingLevel =
|
||||
settingsThinkLevel === "none" || !settingsThinkLevel
|
||||
@@ -159,6 +167,24 @@ function ChatForm({
|
||||
const supportsThinkToggling =
|
||||
selectedModel?.model.toLowerCase().startsWith("deepseek-v3.1") || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (supportsThinkToggling && thinkEnabled && webSearchEnabled) {
|
||||
setSettings({ WebSearchEnabled: false });
|
||||
}
|
||||
}, [
|
||||
selectedModel?.model,
|
||||
supportsThinkToggling,
|
||||
thinkEnabled,
|
||||
webSearchEnabled,
|
||||
setSettings,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cloudDisabled && webSearchEnabled) {
|
||||
setSettings({ WebSearchEnabled: false });
|
||||
}
|
||||
}, [cloudDisabled, webSearchEnabled, setSettings]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setMessage((prev) => ({
|
||||
...prev,
|
||||
@@ -179,8 +205,9 @@ function ChatForm({
|
||||
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
|
||||
errors: Array<{ filename: string; error: string }> = [],
|
||||
) => {
|
||||
// Add valid files to form state
|
||||
if (files.length > 0) {
|
||||
setFileUploadError(null);
|
||||
|
||||
const newAttachments = files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
filename: file.filename,
|
||||
@@ -212,19 +239,19 @@ function ChatForm({
|
||||
|
||||
// Determine if login banner should be shown
|
||||
const shouldShowLoginBanner =
|
||||
!cloudDisabled &&
|
||||
!isLoadingUser &&
|
||||
!isAuthenticated &&
|
||||
((webSearchEnabled && supportsWebSearch) ||
|
||||
(selectedModel?.isCloud() && !airplaneMode));
|
||||
((webSearchEnabled && supportsWebSearch) || selectedModel?.isCloud());
|
||||
|
||||
// Determine which feature to highlight in the banner
|
||||
const getActiveFeatureForBanner = () => {
|
||||
if (cloudDisabled) return null;
|
||||
if (!isAuthenticated) {
|
||||
if (loginPromptFeature) return loginPromptFeature;
|
||||
if (webSearchEnabled && selectedModel?.isCloud() && !airplaneMode)
|
||||
return "webSearch";
|
||||
if (webSearchEnabled && selectedModel?.isCloud()) return "webSearch";
|
||||
if (webSearchEnabled) return "webSearch";
|
||||
if (selectedModel?.isCloud() && !airplaneMode) return "turbo";
|
||||
if (selectedModel?.isCloud()) return "turbo";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -247,11 +274,12 @@ function ChatForm({
|
||||
useEffect(() => {
|
||||
if (
|
||||
isAuthenticated ||
|
||||
(!webSearchEnabled && !!selectedModel?.isCloud() && !airplaneMode)
|
||||
cloudDisabled ||
|
||||
(!webSearchEnabled && !!selectedModel?.isCloud())
|
||||
) {
|
||||
setLoginPromptFeature(null);
|
||||
}
|
||||
}, [isAuthenticated, webSearchEnabled, selectedModel, airplaneMode]);
|
||||
}, [isAuthenticated, webSearchEnabled, selectedModel, cloudDisabled]);
|
||||
|
||||
// When entering edit mode, populate the composition with existing data
|
||||
useEffect(() => {
|
||||
@@ -443,6 +471,10 @@ function ChatForm({
|
||||
const handleSubmit = async () => {
|
||||
if (!message.content.trim() || isStreaming || isDownloading) return;
|
||||
|
||||
if (cloudDisabled && selectedModel?.isCloud()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if cloud mode is enabled but user is not authenticated
|
||||
if (shouldShowLoginBanner) {
|
||||
return;
|
||||
@@ -456,16 +488,13 @@ function ChatForm({
|
||||
}),
|
||||
);
|
||||
|
||||
const useWebSearch = supportsWebSearch && webSearchEnabled && !airplaneMode;
|
||||
|
||||
const useThink = (() => {
|
||||
if (modelSupportsThinkingLevels) {
|
||||
return thinkLevel;
|
||||
} else if (supportsThinkToggling && thinkEnabled) {
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const useWebSearch =
|
||||
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
||||
const useThink = modelSupportsThinkingLevels
|
||||
? thinkLevel
|
||||
: supportsThinkToggling
|
||||
? thinkEnabled
|
||||
: undefined;
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(message.content, {
|
||||
@@ -603,6 +632,62 @@ function ChatForm({
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 24 * 8) + "px";
|
||||
};
|
||||
|
||||
const handleFilesUpload = async () => {
|
||||
try {
|
||||
setFileUploadError(null);
|
||||
|
||||
const results = await window.webview?.selectMultipleFiles();
|
||||
if (results && results.length > 0) {
|
||||
// Convert native dialog results to File objects
|
||||
const files = results
|
||||
.map((result: ImageData) => {
|
||||
if (result.dataURL) {
|
||||
// Convert dataURL back to File object
|
||||
const base64Data = result.dataURL.split(",")[1];
|
||||
const mimeType = result.dataURL.split(";")[0].split(":")[1];
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const file = new File([blob], result.filename, {
|
||||
type: mimeType,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as File[];
|
||||
|
||||
if (files.length > 0) {
|
||||
const { validFiles, errors } = await processFiles(files, {
|
||||
selectedModel,
|
||||
hasVisionCapability,
|
||||
});
|
||||
|
||||
// Send processed files and errors to the same handler as FileUpload
|
||||
if (validFiles.length > 0 || errors.length > 0) {
|
||||
handleFilesReceived(validFiles, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting multiple files:", error);
|
||||
|
||||
const errorEvent = new ErrorEvent({
|
||||
eventName: "error" as const,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to select files",
|
||||
code: "file_selection_error",
|
||||
details:
|
||||
"An error occurred while trying to open the file selection dialog. Please try again.",
|
||||
});
|
||||
|
||||
setFileUploadError(errorEvent);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={`pb-3 px-3 ${hasMessages ? "mt-auto" : "my-auto"}`}>
|
||||
{chatId === "new" && <Logo />}
|
||||
@@ -633,6 +718,8 @@ function ChatForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File upload error message */}
|
||||
{fileUploadError && <ErrorMessage error={fileUploadError} />}
|
||||
<div
|
||||
className={`relative mx-auto flex bg-neutral-100 w-full max-w-[768px] flex-col items-center rounded-3xl pb-2 pt-4 dark:bg-neutral-800 dark:border-neutral-700 min-h-[88px] transition-opacity duration-200 ${isDisabled ? "opacity-50" : "opacity-100"}`}
|
||||
>
|
||||
@@ -771,67 +858,78 @@ function ChatForm({
|
||||
{/* Controls */}
|
||||
<div className="flex w-full items-center justify-end gap-2 px-3 pt-2">
|
||||
{/* Tool buttons - animate from underneath model picker */}
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
<div className={`flex gap-2`}>
|
||||
{/* Thinking Level Button */}
|
||||
{modelSupportsThinkingLevels && (
|
||||
<>
|
||||
<ThinkButton
|
||||
mode="thinkingLevel"
|
||||
ref={thinkingLevelButtonRef}
|
||||
isVisible={modelSupportsThinkingLevels}
|
||||
currentLevel={thinkLevel}
|
||||
onLevelChange={setThinkingLevel}
|
||||
onDropdownToggle={handleThinkingLevelDropdownToggle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Think Button turn on and off */}
|
||||
{supportsThinkToggling && !modelSupportsThinkingLevels && (
|
||||
<>
|
||||
<ThinkButton
|
||||
mode="think"
|
||||
ref={thinkButtonRef}
|
||||
isVisible={
|
||||
supportsThinkToggling && !modelSupportsThinkingLevels
|
||||
}
|
||||
isActive={thinkEnabled}
|
||||
onToggle={() => {
|
||||
// DeepSeek-v3 specific - thinking and web search are mutually exclusive
|
||||
if (supportsThinkToggling) {
|
||||
const enable = !thinkEnabled;
|
||||
setSettings({
|
||||
ThinkEnabled: enable,
|
||||
...(enable ? { WebSearchEnabled: false } : {}),
|
||||
});
|
||||
return;
|
||||
{!isDisabled && (
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
<div className={`flex gap-2`}>
|
||||
{/* File Upload Buttons */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilesUpload}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white dark:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer border border-transparent"
|
||||
title="Upload multiple files"
|
||||
>
|
||||
<PlusIcon className="w-4.5 h-4.5 stroke-2 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
{/* Thinking Level Button */}
|
||||
{modelSupportsThinkingLevels && (
|
||||
<>
|
||||
<ThinkButton
|
||||
mode="thinkingLevel"
|
||||
ref={thinkingLevelButtonRef}
|
||||
isVisible={modelSupportsThinkingLevels}
|
||||
currentLevel={thinkLevel}
|
||||
onLevelChange={setThinkingLevel}
|
||||
onDropdownToggle={handleThinkingLevelDropdownToggle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Think Button turn on and off */}
|
||||
{supportsThinkToggling && !modelSupportsThinkingLevels && (
|
||||
<>
|
||||
<ThinkButton
|
||||
mode="think"
|
||||
ref={thinkButtonRef}
|
||||
isVisible={
|
||||
supportsThinkToggling && !modelSupportsThinkingLevels
|
||||
}
|
||||
setSettings({ ThinkEnabled: !thinkEnabled });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<WebSearchButton
|
||||
ref={webSearchButtonRef}
|
||||
isVisible={supportsWebSearch && airplaneMode === false}
|
||||
isActive={webSearchEnabled}
|
||||
onToggle={() => {
|
||||
if (!webSearchEnabled && !isAuthenticated) {
|
||||
setLoginPromptFeature("webSearch");
|
||||
}
|
||||
const enable = !webSearchEnabled;
|
||||
if (supportsThinkToggling && enable) {
|
||||
setSettings({
|
||||
WebSearchEnabled: true,
|
||||
ThinkEnabled: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSettings({ WebSearchEnabled: enable });
|
||||
}}
|
||||
/>
|
||||
isActive={thinkEnabled}
|
||||
onToggle={() => {
|
||||
// DeepSeek-v3 specific - thinking and web search are mutually exclusive
|
||||
if (supportsThinkToggling) {
|
||||
const enable = !thinkEnabled;
|
||||
setSettings({
|
||||
ThinkEnabled: enable,
|
||||
...(enable ? { WebSearchEnabled: false } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSettings({ ThinkEnabled: !thinkEnabled });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<WebSearchButton
|
||||
ref={webSearchButtonRef}
|
||||
isVisible={supportsWebSearch && cloudDisabled === false}
|
||||
isActive={webSearchEnabled}
|
||||
onToggle={() => {
|
||||
if (!webSearchEnabled && !isAuthenticated) {
|
||||
setLoginPromptFeature("webSearch");
|
||||
}
|
||||
const enable = !webSearchEnabled;
|
||||
if (supportsThinkToggling && enable) {
|
||||
setSettings({
|
||||
WebSearchEnabled: true,
|
||||
ThinkEnabled: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSettings({ WebSearchEnabled: enable });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model picker and submit button */}
|
||||
<div className="flex items-center gap-2 relative z-20">
|
||||
@@ -853,6 +951,7 @@ function ChatForm({
|
||||
!isDownloading &&
|
||||
(!message.content.trim() ||
|
||||
shouldShowLoginBanner ||
|
||||
(cloudDisabled && selectedModel?.isCloud()) ||
|
||||
message.fileErrors.length > 0)
|
||||
}
|
||||
className={`flex items-center justify-center h-9 w-9 rounded-full disabled:cursor-default cursor-pointer bg-black text-white dark:bg-white dark:text-black disabled:opacity-10 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type JSX } from "react";
|
||||
|
||||
interface FileToolsButtonProps {
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
onToggle: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export default function FileToolsButton({
|
||||
enabled,
|
||||
active,
|
||||
onToggle,
|
||||
}: FileToolsButtonProps): JSX.Element | null {
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!active)}
|
||||
title="Toggle File Tools"
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-white dark:bg-neutral-700 focus:outline-none transition-all cursor-pointer border border-transparent ${
|
||||
active
|
||||
? "text-[rgba(0,115,255,1)]"
|
||||
: "text-neutral-800 dark:text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,48 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { DocumentPlusIcon } from "@heroicons/react/24/outline";
|
||||
import type { Model } from "@/gotypes";
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = [
|
||||
"pdf",
|
||||
"docx",
|
||||
"txt",
|
||||
"md",
|
||||
"csv",
|
||||
"json",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"py",
|
||||
"java",
|
||||
"cpp",
|
||||
"c",
|
||||
"cc",
|
||||
"h",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"swift",
|
||||
"kt",
|
||||
"scala",
|
||||
"sh",
|
||||
"bat",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"log",
|
||||
"rtf",
|
||||
];
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
|
||||
import { processFiles as processFilesUtil } from "@/utils/fileValidation";
|
||||
|
||||
interface FileUploadProps {
|
||||
children: ReactNode;
|
||||
@@ -77,30 +36,11 @@ export function FileUpload({
|
||||
// Prevents flickering when dragging over child elements within the component
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const MAX_FILE_SIZE = maxFileSize * 1024 * 1024; // Convert MB to bytes
|
||||
const ALLOWED_EXTENSIONS = allowedExtensions || [
|
||||
...TEXT_FILE_EXTENSIONS,
|
||||
...IMAGE_EXTENSIONS,
|
||||
];
|
||||
|
||||
// Helper function to check if dragging files
|
||||
const hasFiles = useCallback((dataTransfer: DataTransfer) => {
|
||||
return dataTransfer.types.includes("Files");
|
||||
}, []);
|
||||
|
||||
// Helper function to read file as Uint8Array
|
||||
const readFileAsBytes = useCallback((file: File): Promise<Uint8Array> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const arrayBuffer = reader.result as ArrayBuffer;
|
||||
resolve(new Uint8Array(arrayBuffer));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper function to read directory contents
|
||||
const readDirectory = useCallback(
|
||||
async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
|
||||
@@ -144,12 +84,6 @@ export function FileUpload({
|
||||
// Main file processing function
|
||||
const processFiles = useCallback(
|
||||
async (dataTransfer: DataTransfer) => {
|
||||
const attachments: Array<{
|
||||
filename: string;
|
||||
data: Uint8Array;
|
||||
type?: string;
|
||||
}> = [];
|
||||
const errors: Array<{ filename: string; error: string }> = [];
|
||||
const allFiles: File[] = [];
|
||||
|
||||
// Extract files from DataTransfer
|
||||
@@ -171,83 +105,26 @@ export function FileUpload({
|
||||
allFiles.push(...Array.from(dataTransfer.files));
|
||||
}
|
||||
|
||||
// First pass: Check file sizes and types
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
const fileExtension = file.name.toLowerCase().split(".").pop();
|
||||
|
||||
// Custom validation first
|
||||
if (validateFile) {
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: validation.error || "File validation failed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default validation
|
||||
if (!fileExtension) {
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: "File type not supported",
|
||||
});
|
||||
} else if (
|
||||
IMAGE_EXTENSIONS.includes(fileExtension) &&
|
||||
!hasVisionCapability
|
||||
) {
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: "This model does not support images",
|
||||
});
|
||||
} else if (!ALLOWED_EXTENSIONS.includes(fileExtension)) {
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: "File type not supported",
|
||||
});
|
||||
} else if (file.size > MAX_FILE_SIZE) {
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: "File too large",
|
||||
});
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Process only valid files
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
const fileBytes = await readFileAsBytes(file);
|
||||
attachments.push({
|
||||
filename: file.name,
|
||||
data: fileBytes,
|
||||
type: file.type || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file.name}:`, error);
|
||||
errors.push({
|
||||
filename: file.name,
|
||||
error: "Error reading file",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Use shared validation utility
|
||||
const { validFiles, errors } = await processFilesUtil(allFiles, {
|
||||
maxFileSize,
|
||||
allowedExtensions,
|
||||
hasVisionCapability,
|
||||
selectedModel,
|
||||
customValidator: validateFile,
|
||||
});
|
||||
|
||||
// Send processed files and errors back to parent
|
||||
if (attachments.length > 0 || errors.length > 0) {
|
||||
onFilesAdded(attachments, errors);
|
||||
if (validFiles.length > 0 || errors.length > 0) {
|
||||
onFilesAdded(validFiles, errors);
|
||||
}
|
||||
},
|
||||
[
|
||||
readFileAsBytes,
|
||||
readDirectory,
|
||||
selectedModel,
|
||||
hasVisionCapability,
|
||||
ALLOWED_EXTENSIONS,
|
||||
MAX_FILE_SIZE,
|
||||
allowedExtensions,
|
||||
maxFileSize,
|
||||
validateFile,
|
||||
onFilesAdded,
|
||||
],
|
||||
|
||||
@@ -613,7 +613,7 @@ function ToolCallDisplay({
|
||||
return (
|
||||
<div className="text-neutral-600 dark:text-neutral-400 relative select-text">
|
||||
<svg
|
||||
className="h-4 w-4 absolute top-1 left-5"
|
||||
className="h-4 w-4 absolute top-1.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { Model } from "@/gotypes";
|
||||
import { useSelectedModel } from "@/hooks/useSelectedModel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getModelUpstreamInfo } from "@/api";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||
@@ -34,7 +34,7 @@ export const ModelPicker = forwardRef<
|
||||
chatId,
|
||||
searchQuery,
|
||||
);
|
||||
const { settings } = useSettings();
|
||||
const { cloudDisabled } = useCloudStatus();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -44,7 +44,13 @@ export const ModelPicker = forwardRef<
|
||||
}>(null);
|
||||
|
||||
const checkModelStaleness = async (model: Model) => {
|
||||
if (!model || !model.model || model.needs_download) return;
|
||||
if (
|
||||
!model ||
|
||||
!model.model ||
|
||||
model.digest === undefined ||
|
||||
model.digest === ""
|
||||
)
|
||||
return;
|
||||
|
||||
// Check cache - only check staleness every 5 minutes per model
|
||||
const now = Date.now();
|
||||
@@ -213,7 +219,7 @@ export const ModelPicker = forwardRef<
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
airplaneMode={settings.airplaneMode}
|
||||
cloudDisabled={cloudDisabled}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
@@ -227,13 +233,13 @@ export const ModelList = forwardRef(function ModelList(
|
||||
models,
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
airplaneMode,
|
||||
cloudDisabled,
|
||||
isOpen,
|
||||
}: {
|
||||
models: Model[];
|
||||
selectedModel: Model | null;
|
||||
onModelSelect: (model: Model) => void;
|
||||
airplaneMode: boolean;
|
||||
cloudDisabled: boolean;
|
||||
isOpen: boolean;
|
||||
},
|
||||
ref,
|
||||
@@ -317,9 +323,7 @@ export const ModelList = forwardRef(function ModelList(
|
||||
) : (
|
||||
models.map((model, index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${model.model}-${model.digest || "no-digest"}-${model.needs_download ? "download" : "local"}-${index}`}
|
||||
>
|
||||
<div key={`${model.model}-${model.digest || "no-digest"}-${index}`}>
|
||||
<button
|
||||
onClick={() => onModelSelect(model)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
@@ -343,8 +347,8 @@ export const ModelList = forwardRef(function ModelList(
|
||||
<path d="M4.01511 14.5861H14.2304C16.9183 14.5861 19.0002 12.5509 19.0002 9.9403C19.0002 7.30491 16.8911 5.3046 14.0203 5.3046C12.9691 3.23016 11.0602 2 8.69505 2C5.62816 2 3.04822 4.32758 2.72935 7.47455C1.12954 7.95356 0.0766602 9.29431 0.0766602 10.9757C0.0766602 12.9913 1.55776 14.5861 4.01511 14.5861ZM4.02056 13.1261C2.46452 13.1261 1.53673 12.2938 1.53673 11.0161C1.53673 9.91553 2.24207 9.12934 3.51367 8.79302C3.95684 8.68258 4.11901 8.48427 4.16138 8.00729C4.39317 5.3613 6.29581 3.46007 8.69505 3.46007C10.5231 3.46007 11.955 4.48273 12.8385 6.26013C13.0338 6.65439 13.2626 6.7882 13.7488 6.7882C16.1671 6.7882 17.5337 8.19719 17.5337 9.97707C17.5337 11.7526 16.1242 13.1261 14.2852 13.1261H4.02056Z" />
|
||||
</svg>
|
||||
)}
|
||||
{(model.needs_download || model.digest === undefined) &&
|
||||
(airplaneMode || !model.isCloud()) && (
|
||||
{model.digest === undefined &&
|
||||
(cloudDisabled || !model.isCloud()) && (
|
||||
<ArrowDownTrayIcon
|
||||
className="h-4 w-4 text-neutral-500 dark:text-neutral-400"
|
||||
strokeWidth={1.75}
|
||||
|
||||
@@ -11,15 +11,24 @@ import {
|
||||
FolderIcon,
|
||||
BoltIcon,
|
||||
WrenchIcon,
|
||||
CloudIcon,
|
||||
XMarkIcon,
|
||||
CogIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getSettings, updateSettings } from "@/api";
|
||||
import {
|
||||
getSettings,
|
||||
type CloudStatusResponse,
|
||||
updateCloudSetting,
|
||||
updateSettings,
|
||||
getInferenceCompute,
|
||||
} from "@/api";
|
||||
|
||||
function AnimatedDots() {
|
||||
return (
|
||||
@@ -53,6 +62,11 @@ export default function Settings() {
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
cloudDisabled,
|
||||
cloudStatus,
|
||||
isLoading: cloudStatusLoading,
|
||||
} = useCloudStatus();
|
||||
|
||||
const {
|
||||
data: settingsData,
|
||||
@@ -65,6 +79,13 @@ export default function Settings() {
|
||||
|
||||
const settings = settingsData?.settings || null;
|
||||
|
||||
const { data: inferenceComputeResponse } = useQuery({
|
||||
queryKey: ["inferenceCompute"],
|
||||
queryFn: getInferenceCompute,
|
||||
});
|
||||
|
||||
const defaultContextLength = inferenceComputeResponse?.defaultContextLength;
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: updateSettings,
|
||||
onSuccess: () => {
|
||||
@@ -74,6 +95,50 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateCloudMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => updateCloudSetting(enabled),
|
||||
onMutate: async (enabled: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
const previous = queryClient.getQueryData<CloudStatusResponse | null>([
|
||||
"cloudStatus",
|
||||
]);
|
||||
const envForcesDisabled =
|
||||
previous?.source === "env" || previous?.source === "both";
|
||||
|
||||
queryClient.setQueryData<CloudStatusResponse | null>(
|
||||
["cloudStatus"],
|
||||
previous
|
||||
? {
|
||||
...previous,
|
||||
disabled: !enabled || envForcesDisabled,
|
||||
}
|
||||
: {
|
||||
disabled: !enabled,
|
||||
source: "config",
|
||||
},
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _enabled, context) => {
|
||||
if (context?.previous !== undefined) {
|
||||
queryClient.setQueryData(["cloudStatus"], context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: (status) => {
|
||||
queryClient.setQueryData<CloudStatusResponse | null>(
|
||||
["cloudStatus"],
|
||||
status,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
refetchUser();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -148,13 +213,17 @@ export default function Settings() {
|
||||
Models: "",
|
||||
Agent: false,
|
||||
Tools: false,
|
||||
ContextLength: 4096,
|
||||
AirplaneMode: false,
|
||||
ContextLength: 0,
|
||||
});
|
||||
updateSettingsMutation.mutate(defaultSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const cloudOverriddenByEnv =
|
||||
cloudStatus?.source === "env" || cloudStatus?.source === "both";
|
||||
const cloudToggleDisabled =
|
||||
cloudStatusLoading || updateCloudMutation.isPending || cloudOverriddenByEnv;
|
||||
|
||||
const handleConnectOllamaAccount = async () => {
|
||||
setConnectionError(null);
|
||||
|
||||
@@ -237,7 +306,7 @@ export default function Settings() {
|
||||
<div className="space-y-4 max-w-2xl mx-auto">
|
||||
{/* Connect Ollama Account */}
|
||||
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="p-4">
|
||||
<Field>
|
||||
{isLoading ? (
|
||||
// Loading skeleton, this will only happen if the app started recently
|
||||
@@ -299,9 +368,9 @@ export default function Settings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{user?.avatarURL && (
|
||||
{user?.avatarurl && (
|
||||
<img
|
||||
src={user.avatarURL}
|
||||
src={user.avatarurl}
|
||||
alt={user?.name}
|
||||
className="h-10 w-10 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
@@ -344,6 +413,57 @@ export default function Settings() {
|
||||
{/* Local Configuration */}
|
||||
<div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
|
||||
<div className="space-y-4 p-4">
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<CloudIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
|
||||
<div>
|
||||
<Label>Cloud</Label>
|
||||
<Description>
|
||||
{cloudOverriddenByEnv
|
||||
? "The OLLAMA_NO_CLOUD environment variable is currently forcing cloud off."
|
||||
: "Enable cloud models and web search."}
|
||||
</Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={!cloudDisabled}
|
||||
disabled={cloudToggleDisabled}
|
||||
onChange={(checked) => {
|
||||
if (cloudOverriddenByEnv) {
|
||||
return;
|
||||
}
|
||||
updateCloudMutation.mutate(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Auto Update */}
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<ArrowDownTrayIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
|
||||
<div>
|
||||
<Label>Auto-download updates</Label>
|
||||
<Description>
|
||||
{settings.AutoUpdateEnabled
|
||||
? "Automatically download updates when available."
|
||||
: "Updates will not be downloaded automatically."}
|
||||
</Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={settings.AutoUpdateEnabled}
|
||||
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Expose Ollama */}
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -419,13 +539,11 @@ export default function Settings() {
|
||||
</Description>
|
||||
<div className="mt-3">
|
||||
<Slider
|
||||
value={(() => {
|
||||
// Otherwise use the settings value
|
||||
return settings.ContextLength || 4096;
|
||||
})()}
|
||||
value={settings.ContextLength || defaultContextLength || 0}
|
||||
onChange={(value) => {
|
||||
handleChange("ContextLength", value);
|
||||
}}
|
||||
disabled={!defaultContextLength}
|
||||
options={[
|
||||
{ value: 4096, label: "4k" },
|
||||
{ value: 8192, label: "8k" },
|
||||
@@ -440,35 +558,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
{/* Airplane Mode */}
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<svg
|
||||
className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100"
|
||||
viewBox="0 0 21.5508 17.9033"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21.5508 8.94727C21.542 7.91895 20.1445 7.17188 18.4658 7.17188L14.9238 7.17188C14.4316 7.17188 14.2471 7.09277 13.957 6.75879L8.05078 0.316406C7.86621 0.105469 7.6377 0 7.37402 0L6.35449 0C6.12598 0 5.99414 0.202148 6.1084 0.448242L9.14941 7.17188L4.68457 7.68164L3.09375 4.76367C2.97949 4.54395 2.78613 4.44727 2.49609 4.44727L2.11816 4.44727C1.88965 4.44727 1.74023 4.59668 1.74023 4.8252L1.74023 13.0693C1.74023 13.2979 1.88965 13.4385 2.11816 13.4385L2.49609 13.4385C2.78613 13.4385 2.97949 13.3418 3.09375 13.1309L4.68457 10.2129L9.14941 10.7227L6.1084 17.4463C5.99414 17.6836 6.12598 17.8945 6.35449 17.8945L7.37402 17.8945C7.6377 17.8945 7.86621 17.7803 8.05078 17.5781L13.957 11.127C14.2471 10.8018 14.4316 10.7227 14.9238 10.7227L18.4658 10.7227C20.1445 10.7227 21.542 9.9668 21.5508 8.94727Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<Label>Airplane mode</Label>
|
||||
<Description>
|
||||
Airplane mode keeps data local, disabling cloud models
|
||||
and web search.
|
||||
</Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={settings.AirplaneMode}
|
||||
onChange={(checked) =>
|
||||
handleChange("AirplaneMode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,522 +0,0 @@
|
||||
import { expect, test, suite } from "vitest";
|
||||
import { processStreamingMarkdown } from "@/utils/processStreamingMarkdown";
|
||||
|
||||
suite("common llm outputs that cause issues", () => {
|
||||
test("prefix of bolded list item shouldn't make a horizontal line", () => {
|
||||
// we're going to go in order of incrementally adding characters. This
|
||||
// happens really commonly with LLMs that like to make lists like so:
|
||||
//
|
||||
// * **point 1**: explanatory text
|
||||
// * **point 2**: more explanatory text
|
||||
//
|
||||
// Partial rendering of `*` (A), followed by `* *` (B), followed by `* **`
|
||||
// (C) is a total mess. (A) renders as a single bullet point in an
|
||||
// otherwise empty list, (B) renders as two nested lists (and therefore
|
||||
// two bullet points, styled differently by default in html), and (C)
|
||||
// renders as a horizontal line because in markdown apparently `***` or `*
|
||||
// * *` horizontal rules don't have as strict whitespace rules as I
|
||||
// expected them to
|
||||
|
||||
// these are alone (i.e., they would be the first list item)
|
||||
expect(processStreamingMarkdown("*")).toBe("");
|
||||
expect(processStreamingMarkdown("* *")).toBe("");
|
||||
expect(processStreamingMarkdown("* **")).toBe("");
|
||||
// expect(processStreamingMarkdown("* **b")).toBe("* **b**");
|
||||
|
||||
// with a list item before them
|
||||
expect(
|
||||
processStreamingMarkdown(
|
||||
// prettier-ignore
|
||||
[
|
||||
"* abc",
|
||||
"*"
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("* abc");
|
||||
|
||||
expect(
|
||||
processStreamingMarkdown(
|
||||
// prettier-ignore
|
||||
[
|
||||
"* abc",
|
||||
"* *"
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("* abc");
|
||||
|
||||
expect(
|
||||
processStreamingMarkdown(
|
||||
// prettier-ignore
|
||||
[
|
||||
"* abc",
|
||||
"* **"
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("* abc");
|
||||
});
|
||||
|
||||
test("bolded list items with text should be rendered properly", () => {
|
||||
expect(processStreamingMarkdown("* **abc**")).toBe("* **abc**");
|
||||
});
|
||||
|
||||
test("partially bolded list items should be autoclosed", () => {
|
||||
expect(processStreamingMarkdown("* **abc")).toBe("* **abc**");
|
||||
});
|
||||
|
||||
suite(
|
||||
"partially bolded list items should be autoclosed, even if the last node isn't a text node",
|
||||
() => {
|
||||
test("inline code", () => {
|
||||
expect(
|
||||
processStreamingMarkdown("* **Asynchronous Function `async`*"),
|
||||
).toBe("* **Asynchronous Function `async`**");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
suite("autoclosing bold", () => {
|
||||
suite("endings with no asterisks", () => {
|
||||
test("should autoclose bold", () => {
|
||||
expect(processStreamingMarkdown("**abc")).toBe("**abc**");
|
||||
expect(processStreamingMarkdown("abc **abc")).toBe("abc **abc**");
|
||||
});
|
||||
|
||||
suite("should autoclose, even if the last node isn't a text node", () => {
|
||||
test("inline code", () => {
|
||||
expect(
|
||||
processStreamingMarkdown("* **Asynchronous Function `async`"),
|
||||
).toBe("* **Asynchronous Function `async`**");
|
||||
});
|
||||
|
||||
test("opening ** is at the end of the text", () => {
|
||||
expect(processStreamingMarkdown("abc **`def` jhk [lmn](opq)")).toBe(
|
||||
"abc **`def` jhk [lmn](opq)**",
|
||||
);
|
||||
});
|
||||
|
||||
test("if there's a space after the **, it should NOT be autoclosed", () => {
|
||||
expect(processStreamingMarkdown("abc ** `def` jhk [lmn](opq)")).toBe(
|
||||
"abc \\*\\* `def` jhk [lmn](opq)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should autoclose bold, even if the last node isn't a text node", () => {
|
||||
expect(
|
||||
processStreamingMarkdown("* **Asynchronous Function ( `async`"),
|
||||
).toBe("* **Asynchronous Function ( `async`**");
|
||||
});
|
||||
|
||||
test("whitespace fakeouts should not be modified", () => {
|
||||
expect(processStreamingMarkdown("** abc")).toBe("\\*\\* abc");
|
||||
});
|
||||
|
||||
// TODO(drifkin): arguably this should just be removed entirely, but empty
|
||||
// isn't so bad
|
||||
test("should handle empty bolded items", () => {
|
||||
expect(processStreamingMarkdown("**")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
suite("partially closed bolded items", () => {
|
||||
test("simple partial", () => {
|
||||
expect(processStreamingMarkdown("**abc*")).toBe("**abc**");
|
||||
});
|
||||
|
||||
test("partial with non-text node at end", () => {
|
||||
expect(processStreamingMarkdown("**abc`def`*")).toBe("**abc`def`**");
|
||||
});
|
||||
|
||||
test("partial with multiply nested ending nodes", () => {
|
||||
expect(processStreamingMarkdown("**abc[abc](`def`)*")).toBe(
|
||||
"**abc[abc](`def`)**",
|
||||
);
|
||||
});
|
||||
|
||||
test("normal emphasis should not be affected", () => {
|
||||
expect(processStreamingMarkdown("*abc*")).toBe("*abc*");
|
||||
});
|
||||
|
||||
test("normal emphasis with nested code should not be affected", () => {
|
||||
expect(processStreamingMarkdown("*`abc`*")).toBe("*`abc`*");
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("shouldn't autoclose immediately if there's a space before the closing *", () => {
|
||||
expect(processStreamingMarkdown("**abc *")).toBe("**abc**");
|
||||
});
|
||||
|
||||
// skipping for now because this requires partial link completion as well
|
||||
suite.skip("nested blocks that each need autoclosing", () => {
|
||||
test("emph nested in link nested in strong nested in list item", () => {
|
||||
expect(processStreamingMarkdown("* **[abc **def")).toBe(
|
||||
"* **[abc **def**]()**",
|
||||
);
|
||||
});
|
||||
|
||||
test("* **[ab *`def`", () => {
|
||||
expect(processStreamingMarkdown("* **[ab *`def`")).toBe(
|
||||
"* **[ab *`def`*]()**",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite("numbered list items", () => {
|
||||
test("should remove trailing numbers", () => {
|
||||
expect(processStreamingMarkdown("1. First\n2")).toBe("1. First");
|
||||
});
|
||||
|
||||
test("should remove trailing numbers with breaks before", () => {
|
||||
expect(processStreamingMarkdown("1. First \n2")).toBe("1. First");
|
||||
});
|
||||
|
||||
test("should remove trailing numbers that form a new paragraph", () => {
|
||||
expect(processStreamingMarkdown("1. First\n\n2")).toBe("1. First");
|
||||
});
|
||||
|
||||
test("but should leave list items separated by two newlines", () => {
|
||||
expect(processStreamingMarkdown("1. First\n\n2. S")).toBe(
|
||||
"1. First\n\n2. S",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO(drifkin):slop tests ahead, some are decent, but need to manually go
|
||||
// through them as I implement
|
||||
/*
|
||||
describe("StreamingMarkdownContent - processStreamingMarkdown", () => {
|
||||
describe("Ambiguous endings removal", () => {
|
||||
it("should remove list markers at the end", () => {
|
||||
expect(processStreamingMarkdown("Some text\n* ")).toBe("Some text");
|
||||
expect(processStreamingMarkdown("Some text\n*")).toBe("Some text");
|
||||
expect(processStreamingMarkdown("* Item 1\n- ")).toBe("* Item 1");
|
||||
expect(processStreamingMarkdown("* Item 1\n-")).toBe("* Item 1");
|
||||
expect(processStreamingMarkdown("Text\n+ ")).toBe("Text");
|
||||
expect(processStreamingMarkdown("Text\n+")).toBe("Text");
|
||||
expect(processStreamingMarkdown("1. First\n2. ")).toBe("1. First");
|
||||
});
|
||||
|
||||
it("should remove heading markers at the end", () => {
|
||||
expect(processStreamingMarkdown("Some text\n# ")).toBe("Some text");
|
||||
expect(processStreamingMarkdown("Some text\n#")).toBe("Some text\n#"); // # without space is not removed
|
||||
expect(processStreamingMarkdown("# Title\n## ")).toBe("# Title");
|
||||
expect(processStreamingMarkdown("# Title\n##")).toBe("# Title\n##"); // ## without space is not removed
|
||||
});
|
||||
|
||||
it("should remove ambiguous bold markers at the end", () => {
|
||||
expect(processStreamingMarkdown("Text **")).toBe("Text ");
|
||||
expect(processStreamingMarkdown("Some text\n**")).toBe("Some text");
|
||||
});
|
||||
|
||||
it("should remove code block markers at the end", () => {
|
||||
expect(processStreamingMarkdown("Text\n```")).toBe("Text");
|
||||
expect(processStreamingMarkdown("```")).toBe("");
|
||||
});
|
||||
|
||||
it("should remove single backtick at the end", () => {
|
||||
expect(processStreamingMarkdown("Text `")).toBe("Text ");
|
||||
expect(processStreamingMarkdown("`")).toBe("");
|
||||
});
|
||||
|
||||
it("should remove single asterisk at the end", () => {
|
||||
expect(processStreamingMarkdown("Text *")).toBe("Text ");
|
||||
expect(processStreamingMarkdown("*")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
expect(processStreamingMarkdown("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle single line removals correctly", () => {
|
||||
expect(processStreamingMarkdown("* ")).toBe("");
|
||||
expect(processStreamingMarkdown("# ")).toBe("");
|
||||
expect(processStreamingMarkdown("**")).toBe("");
|
||||
expect(processStreamingMarkdown("`")).toBe("");
|
||||
});
|
||||
|
||||
it("shouldn't have this regexp capture group bug", () => {
|
||||
expect(
|
||||
processStreamingMarkdown("Here's a shopping list:\n*"),
|
||||
).not.toContain("0*");
|
||||
expect(processStreamingMarkdown("Here's a shopping list:\n*")).toBe(
|
||||
"Here's a shopping list:",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("List markers", () => {
|
||||
it("should preserve complete list items", () => {
|
||||
expect(processStreamingMarkdown("* Complete item")).toBe(
|
||||
"* Complete item",
|
||||
);
|
||||
expect(processStreamingMarkdown("- Another item")).toBe("- Another item");
|
||||
expect(processStreamingMarkdown("+ Plus item")).toBe("+ Plus item");
|
||||
expect(processStreamingMarkdown("1. Numbered item")).toBe(
|
||||
"1. Numbered item",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle indented list markers", () => {
|
||||
expect(processStreamingMarkdown(" * ")).toBe(" ");
|
||||
expect(processStreamingMarkdown(" - ")).toBe(" ");
|
||||
expect(processStreamingMarkdown("\t+ ")).toBe("\t");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heading markers", () => {
|
||||
it("should preserve complete headings", () => {
|
||||
expect(processStreamingMarkdown("# Complete Heading")).toBe(
|
||||
"# Complete Heading",
|
||||
);
|
||||
expect(processStreamingMarkdown("## Subheading")).toBe("## Subheading");
|
||||
expect(processStreamingMarkdown("### H3 Title")).toBe("### H3 Title");
|
||||
});
|
||||
|
||||
it("should not affect # in other contexts", () => {
|
||||
expect(processStreamingMarkdown("C# programming")).toBe("C# programming");
|
||||
expect(processStreamingMarkdown("Issue #123")).toBe("Issue #123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bold text", () => {
|
||||
it("should close incomplete bold text", () => {
|
||||
expect(processStreamingMarkdown("This is **bold text")).toBe(
|
||||
"This is **bold text**",
|
||||
);
|
||||
expect(processStreamingMarkdown("Start **bold and more")).toBe(
|
||||
"Start **bold and more**",
|
||||
);
|
||||
expect(processStreamingMarkdown("**just bold")).toBe("**just bold**");
|
||||
});
|
||||
|
||||
it("should not affect complete bold text", () => {
|
||||
expect(processStreamingMarkdown("**complete bold**")).toBe(
|
||||
"**complete bold**",
|
||||
);
|
||||
expect(processStreamingMarkdown("Text **bold** more")).toBe(
|
||||
"Text **bold** more",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle nested bold correctly", () => {
|
||||
expect(processStreamingMarkdown("**bold** and **another")).toBe(
|
||||
"**bold** and **another**",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Italic text", () => {
|
||||
it("should close incomplete italic text", () => {
|
||||
expect(processStreamingMarkdown("This is *italic text")).toBe(
|
||||
"This is *italic text*",
|
||||
);
|
||||
expect(processStreamingMarkdown("Start *italic and more")).toBe(
|
||||
"Start *italic and more*",
|
||||
);
|
||||
});
|
||||
|
||||
it("should differentiate between list markers and italic", () => {
|
||||
expect(processStreamingMarkdown("* Item\n* ")).toBe("* Item");
|
||||
expect(processStreamingMarkdown("Some *italic text")).toBe(
|
||||
"Some *italic text*",
|
||||
);
|
||||
expect(processStreamingMarkdown("*just italic")).toBe("*just italic*");
|
||||
});
|
||||
|
||||
it("should not affect complete italic text", () => {
|
||||
expect(processStreamingMarkdown("*complete italic*")).toBe(
|
||||
"*complete italic*",
|
||||
);
|
||||
expect(processStreamingMarkdown("Text *italic* more")).toBe(
|
||||
"Text *italic* more",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Code blocks", () => {
|
||||
it("should close incomplete code blocks", () => {
|
||||
expect(processStreamingMarkdown("```javascript\nconst x = 42;")).toBe(
|
||||
"```javascript\nconst x = 42;\n```",
|
||||
);
|
||||
expect(processStreamingMarkdown("```\ncode here")).toBe(
|
||||
"```\ncode here\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect complete code blocks", () => {
|
||||
expect(processStreamingMarkdown("```\ncode\n```")).toBe("```\ncode\n```");
|
||||
expect(processStreamingMarkdown("```js\nconst x = 1;\n```")).toBe(
|
||||
"```js\nconst x = 1;\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle nested code blocks correctly", () => {
|
||||
expect(processStreamingMarkdown("```\ncode\n```\n```python")).toBe(
|
||||
"```\ncode\n```\n```python\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not process markdown inside code blocks", () => {
|
||||
expect(processStreamingMarkdown("```\n* not a list\n**not bold**")).toBe(
|
||||
"```\n* not a list\n**not bold**\n```",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Inline code", () => {
|
||||
it("should close incomplete inline code", () => {
|
||||
expect(processStreamingMarkdown("This is `inline code")).toBe(
|
||||
"This is `inline code`",
|
||||
);
|
||||
expect(processStreamingMarkdown("Use `console.log")).toBe(
|
||||
"Use `console.log`",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect complete inline code", () => {
|
||||
expect(processStreamingMarkdown("`complete code`")).toBe(
|
||||
"`complete code`",
|
||||
);
|
||||
expect(processStreamingMarkdown("Use `code` here")).toBe(
|
||||
"Use `code` here",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple inline codes correctly", () => {
|
||||
expect(processStreamingMarkdown("`code` and `more")).toBe(
|
||||
"`code` and `more`",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not confuse inline code with code blocks", () => {
|
||||
expect(processStreamingMarkdown("```\nblock\n```\n`inline")).toBe(
|
||||
"```\nblock\n```\n`inline`",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex streaming scenarios", () => {
|
||||
it("should handle progressive streaming of a heading", () => {
|
||||
const steps = [
|
||||
{ input: "#", expected: "#" }, // # alone is not removed (needs space)
|
||||
{ input: "# ", expected: "" },
|
||||
{ input: "# H", expected: "# H" },
|
||||
{ input: "# Hello", expected: "# Hello" },
|
||||
];
|
||||
steps.forEach(({ input, expected }) => {
|
||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle progressive streaming of bold text", () => {
|
||||
const steps = [
|
||||
{ input: "*", expected: "" },
|
||||
{ input: "**", expected: "" },
|
||||
{ input: "**b", expected: "**b**" },
|
||||
{ input: "**bold", expected: "**bold**" },
|
||||
{ input: "**bold**", expected: "**bold**" },
|
||||
];
|
||||
steps.forEach(({ input, expected }) => {
|
||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiline content with various patterns", () => {
|
||||
const multiline = `# Title
|
||||
|
||||
This is a paragraph with **bold text** and *italic text*.
|
||||
|
||||
* Item 1
|
||||
* Item 2
|
||||
* `;
|
||||
|
||||
const expected = `# Title
|
||||
|
||||
This is a paragraph with **bold text** and *italic text*.
|
||||
|
||||
* Item 1
|
||||
* Item 2`;
|
||||
|
||||
expect(processStreamingMarkdown(multiline)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should only fix the last line", () => {
|
||||
expect(processStreamingMarkdown("# Complete\n# Another\n# ")).toBe(
|
||||
"# Complete\n# Another",
|
||||
);
|
||||
expect(processStreamingMarkdown("* Item 1\n* Item 2\n* ")).toBe(
|
||||
"* Item 1\n* Item 2",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed content correctly", () => {
|
||||
const input = `# Header
|
||||
|
||||
This has **bold** text and *italic* text.
|
||||
|
||||
\`\`\`js
|
||||
const x = 42;
|
||||
\`\`\`
|
||||
|
||||
Now some \`inline code\` and **unclosed bold`;
|
||||
|
||||
const expected = `# Header
|
||||
|
||||
This has **bold** text and *italic* text.
|
||||
|
||||
\`\`\`js
|
||||
const x = 42;
|
||||
\`\`\`
|
||||
|
||||
Now some \`inline code\` and **unclosed bold**`;
|
||||
|
||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases with escaping", () => {
|
||||
it("should handle escaped asterisks (future enhancement)", () => {
|
||||
// Note: Current implementation doesn't handle escaping
|
||||
// This is a known limitation - escaped characters still trigger closing
|
||||
expect(processStreamingMarkdown("Text \\*not italic")).toBe(
|
||||
"Text \\*not italic*",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle escaped backticks (future enhancement)", () => {
|
||||
// Note: Current implementation doesn't handle escaping
|
||||
// This is a known limitation - escaped characters still trigger closing
|
||||
expect(processStreamingMarkdown("Text \\`not code")).toBe(
|
||||
"Text \\`not code`",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Code block edge cases", () => {
|
||||
it("should handle triple backticks in the middle of lines", () => {
|
||||
expect(processStreamingMarkdown("Text ``` in middle")).toBe(
|
||||
"Text ``` in middle\n```",
|
||||
);
|
||||
expect(processStreamingMarkdown("```\nText ``` in code\nmore")).toBe(
|
||||
"```\nText ``` in code\nmore\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly close code blocks with language specifiers", () => {
|
||||
expect(processStreamingMarkdown("```typescript")).toBe(
|
||||
"```typescript\n```",
|
||||
);
|
||||
expect(processStreamingMarkdown("```typescript\nconst x = 1")).toBe(
|
||||
"```typescript\nconst x = 1\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove a completely empty partial code block", () => {
|
||||
expect(processStreamingMarkdown("```\n")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
*/
|
||||
@@ -1,66 +1,123 @@
|
||||
import React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import rehypePrismPlus from "rehype-prism-plus";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkStreamingMarkdown, {
|
||||
type LastNodeInfo,
|
||||
} from "@/utils/remarkStreamingMarkdown";
|
||||
import type { PluggableList } from "unified";
|
||||
import { Streamdown, defaultRemarkPlugins } from "streamdown";
|
||||
import remarkCitationParser from "@/utils/remarkCitationParser";
|
||||
import CopyButton from "./CopyButton";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
import { highlighter } from "@/lib/highlighter";
|
||||
|
||||
interface StreamingMarkdownContentProps {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
onLastNode?: (info: LastNodeInfo) => void;
|
||||
browserToolResult?: any; // TODO: proper type
|
||||
}
|
||||
|
||||
// Helper to extract text from React nodes
|
||||
const extractText = (node: React.ReactNode): string => {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (!node) return "";
|
||||
if (React.isValidElement(node)) {
|
||||
const props = node.props as any;
|
||||
if (props?.children) {
|
||||
return extractText(props.children as React.ReactNode);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractText).join("");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const CodeBlock = React.memo(
|
||||
({ children, className, ...props }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
const extractText = React.useCallback((node: React.ReactNode): string => {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (!node) return "";
|
||||
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
// Extract code and language from children
|
||||
const codeElement = children as React.ReactElement<{
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
const language =
|
||||
codeElement.props.className?.replace(/language-/, "") || "";
|
||||
const codeText = extractText(codeElement.props.children);
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
if (
|
||||
node.props &&
|
||||
typeof node.props === "object" &&
|
||||
"children" in node.props
|
||||
) {
|
||||
return extractText(node.props.children as React.ReactNode);
|
||||
}
|
||||
// Synchronously highlight code using the pre-loaded highlighter
|
||||
const tokens = React.useMemo(() => {
|
||||
if (!highlighter) return null;
|
||||
|
||||
try {
|
||||
return {
|
||||
light: highlighter.codeToTokensBase(codeText, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: "one-light" as any,
|
||||
}),
|
||||
dark: highlighter.codeToTokensBase(codeText, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: "one-dark" as any,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to highlight code:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractText).join("");
|
||||
}
|
||||
|
||||
return "";
|
||||
}, []);
|
||||
|
||||
const language = className?.replace(/language-/, "") || "";
|
||||
}, [codeText, language]);
|
||||
|
||||
return (
|
||||
<div className="relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6">
|
||||
<div className="flex justify-between select-none">
|
||||
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2">
|
||||
{language}
|
||||
</div>
|
||||
<div className="flex select-none">
|
||||
{language && (
|
||||
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2">
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<CopyButton
|
||||
content={extractText(children)}
|
||||
content={codeText}
|
||||
showLabels={true}
|
||||
className="copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800"
|
||||
className="copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 ml-auto"
|
||||
/>
|
||||
</div>
|
||||
<pre className={className} {...props}>
|
||||
{children}
|
||||
{/* Light mode */}
|
||||
<pre className="dark:hidden m-0 bg-neutral-100 text-sm overflow-x-auto p-4">
|
||||
<code className="font-mono text-sm">
|
||||
{tokens?.light
|
||||
? tokens.light.map((line: any, i: number) => (
|
||||
<React.Fragment key={i}>
|
||||
{line.map((token: any, j: number) => (
|
||||
<span
|
||||
key={j}
|
||||
style={{
|
||||
color: token.color,
|
||||
}}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
{i < tokens.light.length - 1 && "\n"}
|
||||
</React.Fragment>
|
||||
))
|
||||
: codeText}
|
||||
</code>
|
||||
</pre>
|
||||
{/* Dark mode */}
|
||||
<pre className="hidden dark:block m-0 bg-neutral-800 text-sm overflow-x-auto p-4">
|
||||
<code className="font-mono text-sm">
|
||||
{tokens?.dark
|
||||
? tokens.dark.map((line: any, i: number) => (
|
||||
<React.Fragment key={i}>
|
||||
{line.map((token: any, j: number) => (
|
||||
<span
|
||||
key={j}
|
||||
style={{
|
||||
color: token.color,
|
||||
}}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
{i < tokens.dark.length - 1 && "\n"}
|
||||
</React.Fragment>
|
||||
))
|
||||
: codeText}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -68,65 +125,19 @@ const CodeBlock = React.memo(
|
||||
);
|
||||
|
||||
const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
React.memo(
|
||||
({ content, isStreaming = false, size, onLastNode, browserToolResult }) => {
|
||||
// Build the remark plugins array
|
||||
const remarkPlugins = React.useMemo(() => {
|
||||
const plugins: PluggableList = [
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkCitationParser,
|
||||
];
|
||||
React.memo(({ content, isStreaming = false, size, browserToolResult }) => {
|
||||
// Build the remark plugins array - keep default GFM and Math, add citations
|
||||
const remarkPlugins = React.useMemo(() => {
|
||||
return [
|
||||
defaultRemarkPlugins.gfm,
|
||||
defaultRemarkPlugins.math,
|
||||
remarkCitationParser,
|
||||
];
|
||||
}, []);
|
||||
|
||||
// Add streaming plugin when in streaming mode
|
||||
if (isStreaming) {
|
||||
plugins.push([remarkStreamingMarkdown, { debug: true, onLastNode }]);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}, [isStreaming, onLastNode]);
|
||||
|
||||
// Create a custom sanitization schema that allows math elements
|
||||
const sanitizeSchema = React.useMemo(() => {
|
||||
return {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
span: [
|
||||
...(defaultSchema.attributes?.span || []),
|
||||
["className", /^katex/],
|
||||
],
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div || []),
|
||||
["className", /^katex/],
|
||||
],
|
||||
"ol-citation": ["cursor", "start", "end"],
|
||||
},
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames || []),
|
||||
"math",
|
||||
"mrow",
|
||||
"mi",
|
||||
"mo",
|
||||
"mn",
|
||||
"msup",
|
||||
"msub",
|
||||
"mfrac",
|
||||
"mover",
|
||||
"munder",
|
||||
"msqrt",
|
||||
"mroot",
|
||||
"merror",
|
||||
"mspace",
|
||||
"mpadded",
|
||||
"ol-citation",
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
max-w-full
|
||||
${size === "sm" ? "prose-sm" : size === "lg" ? "prose-lg" : ""}
|
||||
prose
|
||||
@@ -144,7 +155,27 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
prose-pre:my-0
|
||||
prose-pre:max-w-full
|
||||
prose-pre:pt-1
|
||||
[&_code:not(pre_code)]:text-neutral-700
|
||||
[&_table]:border-collapse
|
||||
[&_table]:w-full
|
||||
[&_table]:border
|
||||
[&_table]:border-neutral-200
|
||||
[&_table]:rounded-lg
|
||||
[&_table]:overflow-hidden
|
||||
[&_th]:px-3
|
||||
[&_th]:py-2
|
||||
[&_th]:text-left
|
||||
[&_th]:font-semibold
|
||||
[&_th]:border-b
|
||||
[&_th]:border-r
|
||||
[&_th]:border-neutral-200
|
||||
[&_th:last-child]:border-r-0
|
||||
[&_td]:px-3
|
||||
[&_td]:py-2
|
||||
[&_td]:border-r
|
||||
[&_td]:border-neutral-200
|
||||
[&_td:last-child]:border-r-0
|
||||
[&_tbody_tr:not(:last-child)_td]:border-b
|
||||
[&_code:not(pre_code)]:text-neutral-700
|
||||
[&_code:not(pre_code)]:bg-neutral-100
|
||||
[&_code:not(pre_code)]:font-normal
|
||||
[&_code:not(pre_code)]:px-1.5
|
||||
@@ -160,6 +191,10 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
dark:prose-strong:text-neutral-200
|
||||
dark:prose-pre:text-neutral-200
|
||||
dark:prose:pre:text-neutral-200
|
||||
dark:[&_table]:border-neutral-700
|
||||
dark:[&_thead]:bg-neutral-800
|
||||
dark:[&_th]:border-neutral-700
|
||||
dark:[&_td]:border-neutral-700
|
||||
dark:[&_code:not(pre_code)]:text-neutral-200
|
||||
dark:[&_code:not(pre_code)]:bg-neutral-800
|
||||
dark:[&_code:not(pre_code)]:font-normal
|
||||
@@ -167,104 +202,86 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
dark:prose-li:marker:text-neutral-300
|
||||
break-words
|
||||
`}
|
||||
>
|
||||
<StreamingMarkdownErrorBoundary
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
>
|
||||
<StreamingMarkdownErrorBoundary
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={
|
||||
[
|
||||
[rehypeRaw, { allowDangerousHtml: true }],
|
||||
[rehypeSanitize, sanitizeSchema],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
[
|
||||
rehypeKatex,
|
||||
{
|
||||
errorColor: "#000000", // Black instead of red for errors
|
||||
strict: false, // Be more lenient with parsing
|
||||
throwOnError: false,
|
||||
},
|
||||
],
|
||||
] as PluggableList
|
||||
}
|
||||
components={{
|
||||
pre: CodeBlock,
|
||||
table: ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableElement>) => (
|
||||
<div className="overflow-x-auto max-w-full">
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
// @ts-expect-error: custom type
|
||||
"ol-citation": ({
|
||||
cursor,
|
||||
// start,
|
||||
// end,
|
||||
}: {
|
||||
cursor: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}) => {
|
||||
// Check if we have a page_stack and if the cursor is valid
|
||||
const pageStack = browserToolResult?.page_stack;
|
||||
const hasValidPage = pageStack && cursor < pageStack.length;
|
||||
const pageUrl = hasValidPage ? pageStack[cursor] : null;
|
||||
<Streamdown
|
||||
parseIncompleteMarkdown={isStreaming}
|
||||
isAnimating={isStreaming}
|
||||
remarkPlugins={remarkPlugins}
|
||||
controls={false}
|
||||
components={{
|
||||
pre: CodeBlock,
|
||||
table: ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableElement>) => (
|
||||
<div className="overflow-x-auto max-w-full">
|
||||
<table
|
||||
{...props}
|
||||
className="border-collapse w-full border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
// @ts-expect-error: custom citation type
|
||||
"ol-citation": ({
|
||||
cursor,
|
||||
}: {
|
||||
cursor: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}) => {
|
||||
const pageStack = browserToolResult?.page_stack;
|
||||
const hasValidPage = pageStack && cursor < pageStack.length;
|
||||
const pageUrl = hasValidPage ? pageStack[cursor] : null;
|
||||
|
||||
// Extract a readable title from the URL if possible
|
||||
const getPageTitle = (url: string) => {
|
||||
if (url.startsWith("search_results_")) {
|
||||
const searchTerm = url.substring(
|
||||
"search_results_".length,
|
||||
);
|
||||
return `Search: ${searchTerm}`;
|
||||
}
|
||||
// For regular URLs, try to extract domain or use full URL
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
// If not a valid URL, return as is
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const citationElement = (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full px-2 py-1 ml-1">
|
||||
[{cursor}]
|
||||
</span>
|
||||
);
|
||||
|
||||
// If we have a valid page URL, wrap in a link
|
||||
if (pageUrl && pageUrl.startsWith("http")) {
|
||||
return (
|
||||
<a
|
||||
href={pageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center hover:opacity-80 transition-opacity no-underline"
|
||||
title={getPageTitle(pageUrl)}
|
||||
>
|
||||
{citationElement}
|
||||
</a>
|
||||
);
|
||||
const getPageTitle = (url: string) => {
|
||||
if (url.startsWith("search_results_")) {
|
||||
const searchTerm = url.substring("search_results_".length);
|
||||
return `Search: ${searchTerm}`;
|
||||
}
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// Otherwise, just return the citation without a link
|
||||
return citationElement;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</StreamingMarkdownErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
const citationElement = (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full px-2 py-1 ml-1">
|
||||
[{cursor}]
|
||||
</span>
|
||||
);
|
||||
|
||||
if (pageUrl && pageUrl.startsWith("http")) {
|
||||
return (
|
||||
<a
|
||||
href={pageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center hover:opacity-80 transition-opacity no-underline"
|
||||
title={getPageTitle(pageUrl)}
|
||||
>
|
||||
{citationElement}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return citationElement;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</StreamingMarkdownErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface StreamingMarkdownErrorBoundaryProps {
|
||||
content: string;
|
||||
|
||||
@@ -50,21 +50,33 @@ export default function Thinking({
|
||||
// Position content to show bottom when collapsed
|
||||
useEffect(() => {
|
||||
if (isCollapsed && contentRef.current && wrapperRef.current) {
|
||||
const contentHeight = contentRef.current.scrollHeight;
|
||||
const wrapperHeight = wrapperRef.current.clientHeight;
|
||||
if (contentHeight > wrapperHeight) {
|
||||
const translateY = -(contentHeight - wrapperHeight);
|
||||
contentRef.current.style.transform = `translateY(${translateY}px)`;
|
||||
setHasOverflow(true);
|
||||
} else {
|
||||
setHasOverflow(false);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!contentRef.current || !wrapperRef.current) return;
|
||||
|
||||
const contentHeight = contentRef.current.scrollHeight;
|
||||
const wrapperHeight = wrapperRef.current.clientHeight;
|
||||
if (contentHeight > wrapperHeight) {
|
||||
const translateY = -(contentHeight - wrapperHeight);
|
||||
contentRef.current.style.transform = `translateY(${translateY}px)`;
|
||||
setHasOverflow(true);
|
||||
} else {
|
||||
contentRef.current.style.transform = "translateY(0)";
|
||||
setHasOverflow(false);
|
||||
}
|
||||
});
|
||||
} else if (contentRef.current) {
|
||||
contentRef.current.style.transform = "translateY(0)";
|
||||
setHasOverflow(false);
|
||||
}
|
||||
}, [thinking, isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activelyThinking && wrapperRef.current && !isCollapsed) {
|
||||
// When expanded and actively thinking, scroll to bottom
|
||||
wrapperRef.current.scrollTop = wrapperRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinking, activelyThinking, isCollapsed]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
setHasUserInteracted(true);
|
||||
@@ -73,8 +85,9 @@ export default function Thinking({
|
||||
// Calculate max height for smooth animations
|
||||
const getMaxHeight = () => {
|
||||
if (isCollapsed) {
|
||||
return finishedThinking ? "0px" : "12rem"; // 8rem = 128px (same as max-h-32)
|
||||
return finishedThinking ? "0px" : "12rem";
|
||||
}
|
||||
// When expanded, use the content height or grow naturally
|
||||
return contentHeight ? `${contentHeight}px` : "none";
|
||||
};
|
||||
|
||||
@@ -131,10 +144,11 @@ export default function Thinking({
|
||||
</div>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md overflow-hidden
|
||||
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2`}
|
||||
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md
|
||||
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2
|
||||
${isCollapsed ? "overflow-hidden" : "overflow-y-auto"}`}
|
||||
style={{
|
||||
maxHeight: getMaxHeight(),
|
||||
maxHeight: isCollapsed ? getMaxHeight() : undefined,
|
||||
opacity: isCollapsed && finishedThinking ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import type React from "react";
|
||||
import { Text } from "./text";
|
||||
|
||||
const sizes = {
|
||||
xs: "sm:max-w-xs",
|
||||
sm: "sm:max-w-sm",
|
||||
md: "sm:max-w-md",
|
||||
lg: "sm:max-w-lg",
|
||||
xl: "sm:max-w-xl",
|
||||
"2xl": "sm:max-w-2xl",
|
||||
"3xl": "sm:max-w-3xl",
|
||||
"4xl": "sm:max-w-4xl",
|
||||
"5xl": "sm:max-w-5xl",
|
||||
};
|
||||
|
||||
export function Alert({
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
size?: keyof typeof sizes;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
} & Omit<Headless.DialogProps, "as" | "className">) {
|
||||
return (
|
||||
<Headless.Dialog {...props}>
|
||||
<Headless.DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||
<Headless.DialogPanel
|
||||
transition
|
||||
className={clsx(
|
||||
className,
|
||||
sizes[size],
|
||||
"row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline",
|
||||
"transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Headless.DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Headless.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertTitle({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.DialogTitleProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.DialogTitle
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.DescriptionProps<typeof Text>,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
as={Text}
|
||||
{...props}
|
||||
className={clsx(className, "mt-2 text-center text-pretty sm:text-left")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return <div {...props} className={clsx(className, "mt-4")} />;
|
||||
}
|
||||
|
||||
export function AlertActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { TouchTarget } from "./button";
|
||||
import { Link } from "./link";
|
||||
|
||||
type AvatarProps = {
|
||||
src?: string | null;
|
||||
square?: boolean;
|
||||
initials?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Avatar({
|
||||
src = null,
|
||||
square = false,
|
||||
initials,
|
||||
alt = "",
|
||||
className,
|
||||
...props
|
||||
}: AvatarProps & React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Basic layout
|
||||
"inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1",
|
||||
"outline -outline-offset-1 outline-black/10 dark:outline-white/10",
|
||||
// Border radius
|
||||
square
|
||||
? "rounded-(--avatar-radius) *:rounded-(--avatar-radius)"
|
||||
: "rounded-full *:rounded-full",
|
||||
)}
|
||||
>
|
||||
{initials && (
|
||||
<svg
|
||||
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden={alt ? undefined : "true"}
|
||||
>
|
||||
{alt && <title>{alt}</title>}
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
alignmentBaseline="middle"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
dy=".125em"
|
||||
>
|
||||
{initials}
|
||||
</text>
|
||||
</svg>
|
||||
)}
|
||||
{src && <img className="size-full" src={src} alt={alt} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const AvatarButton = forwardRef(function AvatarButton(
|
||||
{
|
||||
src,
|
||||
square = false,
|
||||
initials,
|
||||
alt,
|
||||
className,
|
||||
...props
|
||||
}: AvatarProps &
|
||||
(
|
||||
| Omit<Headless.ButtonProps, "as" | "className">
|
||||
| Omit<React.ComponentPropsWithoutRef<typeof Link>, "className">
|
||||
),
|
||||
ref: React.ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
const classes = clsx(
|
||||
className,
|
||||
square ? "rounded-[20%]" : "rounded-full",
|
||||
"relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
|
||||
);
|
||||
|
||||
return "href" in props ? (
|
||||
<Link
|
||||
{...(props as Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Link>,
|
||||
"className"
|
||||
>)}
|
||||
className={classes}
|
||||
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||
>
|
||||
<TouchTarget>
|
||||
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||
</TouchTarget>
|
||||
</Link>
|
||||
) : (
|
||||
<Headless.Button
|
||||
{...(props as Omit<Headless.ButtonProps, "as" | "className">)}
|
||||
className={classes}
|
||||
ref={ref as React.ForwardedRef<HTMLButtonElement>}
|
||||
>
|
||||
<TouchTarget>
|
||||
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||
</TouchTarget>
|
||||
</Headless.Button>
|
||||
);
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import type React from "react";
|
||||
|
||||
export function CheckboxGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Basic groups
|
||||
"space-y-3",
|
||||
// With descriptions
|
||||
"has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldProps, "as" | "className">) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
"grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]",
|
||||
// Control layout
|
||||
"*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1",
|
||||
// Label layout
|
||||
"*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1",
|
||||
// Description layout
|
||||
"*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2",
|
||||
// With description
|
||||
"has-data-[slot=description]:**:data-[slot=label]:font-medium",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const base = [
|
||||
// Basic layout
|
||||
"relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4",
|
||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||
"before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm",
|
||||
// Background color when checked
|
||||
"group-data-checked:before:bg-(--checkbox-checked-bg)",
|
||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||
"dark:before:hidden",
|
||||
// Background color applied to control in dark mode
|
||||
"dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)",
|
||||
// Border
|
||||
"border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)",
|
||||
"dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30",
|
||||
// Inner highlight shadow
|
||||
"after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]",
|
||||
"dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block",
|
||||
// Focus ring
|
||||
"group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500",
|
||||
// Disabled state
|
||||
"group-data-disabled:opacity-50",
|
||||
"group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent",
|
||||
"dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden",
|
||||
// Forced colors mode
|
||||
"forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]",
|
||||
"dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]",
|
||||
];
|
||||
|
||||
const colors = {
|
||||
"dark/zinc": [
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90",
|
||||
"dark:[--checkbox-checked-bg:var(--color-zinc-600)]",
|
||||
],
|
||||
"dark/white": [
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90",
|
||||
"dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15",
|
||||
],
|
||||
white:
|
||||
"[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15",
|
||||
dark: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90",
|
||||
zinc: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90",
|
||||
red: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90",
|
||||
orange:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90",
|
||||
amber:
|
||||
"[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80",
|
||||
yellow:
|
||||
"[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80",
|
||||
lime: "[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80",
|
||||
green:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90",
|
||||
emerald:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90",
|
||||
teal: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90",
|
||||
cyan: "[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80",
|
||||
sky: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80",
|
||||
blue: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90",
|
||||
indigo:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90",
|
||||
violet:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90",
|
||||
purple:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90",
|
||||
fuchsia:
|
||||
"[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90",
|
||||
pink: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90",
|
||||
rose: "[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90",
|
||||
};
|
||||
|
||||
type Color = keyof typeof colors;
|
||||
|
||||
export function Checkbox({
|
||||
color = "dark/zinc",
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
color?: Color;
|
||||
className?: string;
|
||||
} & Omit<Headless.CheckboxProps, "as" | "className">) {
|
||||
return (
|
||||
<Headless.Checkbox
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(className, "group inline-flex focus:outline-hidden")}
|
||||
>
|
||||
<span className={clsx([base, colors[color]])}>
|
||||
<svg
|
||||
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
>
|
||||
{/* Checkmark icon */}
|
||||
<path
|
||||
className="opacity-100 group-data-indeterminate:opacity-0"
|
||||
d="M3 8L6 11L11 3.5"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Indeterminate icon */}
|
||||
<path
|
||||
className="opacity-0 group-data-indeterminate:opacity-100"
|
||||
d="M3 7H11"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Headless.Checkbox>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Combobox<T>({
|
||||
options,
|
||||
displayValue,
|
||||
filter,
|
||||
anchor = "bottom",
|
||||
className,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
options: T[];
|
||||
displayValue: (value: T | null) => string | undefined;
|
||||
filter?: (value: T, query: string) => boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
"aria-label"?: string;
|
||||
children: (value: NonNullable<T>) => React.ReactElement;
|
||||
} & Omit<Headless.ComboboxProps<T, false>, "as" | "multiple" | "children"> & {
|
||||
anchor?: "top" | "bottom";
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options.filter((option) =>
|
||||
filter
|
||||
? filter(option, query)
|
||||
: displayValue(option)?.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Headless.Combobox
|
||||
{...props}
|
||||
multiple={false}
|
||||
virtual={{ options: filteredOptions }}
|
||||
onClose={() => setQuery("")}
|
||||
>
|
||||
<span
|
||||
data-slot="control"
|
||||
className={clsx([
|
||||
className,
|
||||
// Basic layout
|
||||
"relative block w-full",
|
||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||
"before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm",
|
||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||
"dark:before:hidden",
|
||||
// Focus ring
|
||||
"after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500",
|
||||
// Disabled state
|
||||
"has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none",
|
||||
// Invalid state
|
||||
"has-data-invalid:before:shadow-red-500/10",
|
||||
])}
|
||||
>
|
||||
<Headless.ComboboxInput
|
||||
autoFocus={autoFocus}
|
||||
data-slot="control"
|
||||
aria-label={ariaLabel}
|
||||
displayValue={(option: T) => displayValue(option) ?? ""}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={clsx([
|
||||
className,
|
||||
// Basic layout
|
||||
"relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]",
|
||||
// Horizontal padding
|
||||
"pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]",
|
||||
// Typography
|
||||
"text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white",
|
||||
// Border
|
||||
"border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20",
|
||||
// Background color
|
||||
"bg-transparent dark:bg-white/5",
|
||||
// Hide default focus styles
|
||||
"focus:outline-hidden",
|
||||
// Invalid state
|
||||
"data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500",
|
||||
// Disabled state
|
||||
"data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15",
|
||||
// System icons
|
||||
"dark:scheme-dark",
|
||||
])}
|
||||
/>
|
||||
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
|
||||
<svg
|
||||
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M5.75 10.75L8 13L10.25 10.75"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.25 5.25L8 3L5.75 5.25"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Headless.ComboboxButton>
|
||||
</span>
|
||||
<Headless.ComboboxOptions
|
||||
transition
|
||||
anchor={anchor}
|
||||
className={clsx(
|
||||
// Anchor positioning
|
||||
"[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]",
|
||||
// Base styles,
|
||||
"isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible",
|
||||
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||
"outline outline-transparent focus:outline-hidden",
|
||||
// Handle scrolling when menu won't fit in viewport
|
||||
"overflow-y-scroll overscroll-contain",
|
||||
// Popover background
|
||||
"bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75",
|
||||
// Shadows
|
||||
"shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset",
|
||||
// Transitions
|
||||
"transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{({ option }) => children(option)}
|
||||
</Headless.ComboboxOptions>
|
||||
</Headless.Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboboxOption<T>({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: { className?: string; children?: React.ReactNode } & Omit<
|
||||
Headless.ComboboxOptionProps<"div", T>,
|
||||
"as" | "className"
|
||||
>) {
|
||||
let sharedClasses = clsx(
|
||||
// Base
|
||||
"flex min-w-0 items-center",
|
||||
// Icons
|
||||
"*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4",
|
||||
"*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400",
|
||||
"forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]",
|
||||
// Avatars
|
||||
"*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5",
|
||||
);
|
||||
|
||||
return (
|
||||
<Headless.ComboboxOption
|
||||
{...props}
|
||||
className={clsx(
|
||||
// Basic layout
|
||||
"group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3",
|
||||
// Typography
|
||||
"text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]",
|
||||
// Focus
|
||||
"outline-hidden data-focus:bg-blue-500 data-focus:text-white",
|
||||
// Forced colors mode
|
||||
"forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]",
|
||||
// Disabled
|
||||
"data-disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className={clsx(className, sharedClasses)}>{children}</span>
|
||||
<svg
|
||||
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4 8.5l3 3L12 4"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Headless.ComboboxOption>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboboxDescription({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export function DescriptionList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"dl">) {
|
||||
return (
|
||||
<dl
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DescriptionTerm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"dt">) {
|
||||
return (
|
||||
<dt
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DescriptionDetails({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"dd">) {
|
||||
return (
|
||||
<dd
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user